summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/approval/handlers-registry.ts7
-rw-r--r--lib/approval/templates/일반계약 결재.html3024
-rw-r--r--lib/basic-contract/cpvw-service.ts236
-rw-r--r--lib/basic-contract/service.ts267
-rw-r--r--lib/basic-contract/sslvw-service.ts126
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx194
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx39
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx1
-rw-r--r--lib/bidding/actions.ts32
-rw-r--r--lib/bidding/approval-actions.ts18
-rw-r--r--lib/bidding/detail/service.ts508
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx78
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx46
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx228
-rw-r--r--lib/bidding/detail/table/price-adjustment-dialog.tsx195
-rw-r--r--lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx324
-rw-r--r--lib/bidding/handlers.ts132
-rw-r--r--lib/bidding/list/biddings-page-header.tsx10
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx31
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx54
-rw-r--r--lib/bidding/list/export-biddings-to-excel.ts209
-rw-r--r--lib/bidding/manage/export-bidding-items-to-excel.ts161
-rw-r--r--lib/bidding/manage/import-bidding-items-from-excel.ts273
-rw-r--r--lib/bidding/manage/project-utils.ts87
-rw-r--r--lib/bidding/pre-quote/service.ts54
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx808
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx593
-rw-r--r--lib/bidding/selection/actions.ts185
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx14
-rw-r--r--lib/bidding/selection/bidding-item-table.tsx205
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx11
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx7
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx6
-rw-r--r--lib/bidding/selection/selection-result-form.tsx213
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx4
-rw-r--r--lib/bidding/service.ts646
-rw-r--r--lib/bidding/validation.ts2
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx143
-rw-r--r--lib/bidding/vendor/export-partners-biddings-to-excel.ts275
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx1
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx18
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx56
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx1
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx34
-rw-r--r--lib/dolce/actions.ts6
-rw-r--r--lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx2
-rw-r--r--lib/dolce/table/drawing-list-columns.tsx11
-rw-r--r--lib/dolce/table/gtt-drawing-list-columns.tsx13
-rw-r--r--lib/forms-plant/services.ts161
-rw-r--r--lib/general-contracts/approval-actions.ts136
-rw-r--r--lib/general-contracts/approval-template-variables.ts345
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx2266
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx478
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx162
-rw-r--r--lib/general-contracts/handlers.ts157
-rw-r--r--lib/general-contracts/service.ts13
-rw-r--r--lib/information/service.ts32
-rw-r--r--lib/items-tech/table/add-items-dialog.tsx65
-rw-r--r--lib/menu-v2/components/add-node-dialog.tsx186
-rw-r--r--lib/menu-v2/components/domain-tabs.tsx25
-rw-r--r--lib/menu-v2/components/edit-node-dialog.tsx215
-rw-r--r--lib/menu-v2/components/menu-tree-manager.tsx364
-rw-r--r--lib/menu-v2/components/menu-tree.tsx282
-rw-r--r--lib/menu-v2/components/move-to-dialog.tsx87
-rw-r--r--lib/menu-v2/components/unassigned-menus-panel.tsx178
-rw-r--r--lib/menu-v2/permission-service.ts186
-rw-r--r--lib/menu-v2/service.ts605
-rw-r--r--lib/menu-v2/types.ts103
-rw-r--r--lib/procurement-items/service.ts15
-rw-r--r--lib/sedp/get-tags-plant.ts1013
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts22
-rw-r--r--lib/soap/ecc/send/chemical-substance-check.ts449
-rw-r--r--lib/tags-plant/queries.ts2
-rw-r--r--lib/tags-plant/service.ts158
-rw-r--r--lib/tags-plant/table/add-tag-dialog.tsx2
-rw-r--r--lib/tags-plant/table/tag-table-column.tsx19
-rw-r--r--lib/tags-plant/table/tag-table.tsx3
-rw-r--r--lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx406
-rw-r--r--lib/tech-vendors/possible-items/possible-items-table.tsx18
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx17
-rw-r--r--lib/tech-vendors/service.ts261
-rw-r--r--lib/techsales-rfq/repository.ts1
-rw-r--r--lib/techsales-rfq/service.ts197
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx42
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx50
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx22
-rw-r--r--lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx348
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx13
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx7
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx87
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts158
-rw-r--r--lib/vendor-investigation/service.ts3
-rw-r--r--lib/vendor-investigation/validations.ts5
-rw-r--r--lib/vendors/items-table/item-action-dialog.tsx477
94 files changed, 15707 insertions, 3722 deletions
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index beb6b971..235c9b7b 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -40,9 +40,10 @@ export async function initializeApprovalHandlers() {
// 벤더 가입 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveVendorWithMDGInternal)
registerActionHandler('vendor_approval', approveVendorWithMDGInternal);
- // 5. 계약 승인 핸들러
- // const { approveContractInternal } = await import('@/lib/contract/handlers');
- // registerActionHandler('contract_approval', approveContractInternal);
+ // 5. 일반계약 승인 핸들러
+ const { approveContractInternal } = await import('@/lib/general-contracts/handlers');
+ // 일반계약 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveContractInternal)
+ registerActionHandler('general_contract_approval', approveContractInternal);
// 6. RFQ 발송 핸들러 (첨부파일이 있는 경우)
const { sendRfqWithApprovalInternal } = await import('@/lib/rfq-last/approval-handlers');
diff --git a/lib/approval/templates/일반계약 결재.html b/lib/approval/templates/일반계약 결재.html
new file mode 100644
index 00000000..99389030
--- /dev/null
+++ b/lib/approval/templates/일반계약 결재.html
@@ -0,0 +1,3024 @@
+<div
+
+ style="
+
+ max-width: 1000px;
+
+ margin: 0 auto;
+
+ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif;
+
+ font-size: 14px;
+
+ color: #333;
+
+ line-height: 1.5;
+
+ border: 1px solid #666; /* 전체적인 테두리 추가 */
+
+ "
+
+>
+
+ <!-- 1. 제목 및 안내 문구 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 0px;
+
+ border-bottom: 2px solid #000;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ style="
+
+ background-color: #fff;
+
+ color: #000;
+
+ padding: 15px;
+
+ text-align: center;
+
+ font-size: 20px;
+
+ font-weight: 700;
+
+ "
+
+ >
+
+ 계약 체결 진행 품의 요청서 (구매성)
+
+ </th>
+
+ </tr>
+
+ <tr>
+
+ <td
+
+ style="
+
+ padding: 5px 15px;
+
+ text-align: right;
+
+ font-size: 12px;
+
+ color: #666;
+
+ border-bottom: 1px solid #ccc;
+
+ "
+
+ >
+
+ *결재 완료 후 계약 체결을 진행할 수 있습니다.
+
+ <br />
+
+ * 본 계약은 계약 갱신이 불필요하여 만료 알림이 설정되지 않았습니다.
+
+ </td>
+
+ </tr>
+
+ </thead>
+
+ </table>
+
+
+
+ <!-- 2. 계약 기본 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 기본 정보
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 1행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약번호
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 20%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약번호}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약명
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 20%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약명}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약체결방식
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약체결방식}}
+
+ </td>
+
+ </tr>
+
+ <!-- 2행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약종류
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약종류}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 구매담당자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{구매담당자}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 업체선정방식
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{업체선정방식}}
+
+ </td>
+
+ </tr>
+
+ <!-- 3행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 입찰번호
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{입찰번호}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 입찰명
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{입찰명}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약기간
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약기간}}
+
+ </td>
+
+ </tr>
+
+ <!-- 4행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약일자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약일자}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 매입 부가가치세
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{매입_부가가치세}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 담당자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약_담당자}}
+
+ </td>
+
+ </tr>
+
+ <!-- 5행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약부서
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약부서}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 금액
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약금액}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 지급조건
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_지급조건}}
+
+ </td>
+
+ </tr>
+
+ <!-- 6행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 인도조건
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_인도조건}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 인도조건(옵션)
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_인도조건_옵션}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 선적지
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{선적지}}
+
+ </td>
+
+ </tr>
+
+ <!-- 7행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 하역지
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{하역지}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 사외업체 야드 투입 여부
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{사외업체_야드_투입여부}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 프로젝트
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{프로젝트}}
+
+ </td>
+
+ </tr>
+
+ <!-- 8행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 직종
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{직종}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 재하도 협력사
+
+ </td>
+
+ <td
+
+ colspan="3"
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{재하도_협력사}}
+
+ </td>
+
+ </tr>
+
+ <!-- 9행: 계약 내용 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 내용
+
+ </td>
+
+ <td
+
+ colspan="5"
+
+ style="
+
+ padding: 8px 10px;
+
+ height: 80px;
+
+ border: 1px solid #ccc;
+
+ vertical-align: top;
+
+ "
+
+ >
+
+ {{계약내용}}
+
+ </td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 3. 계약 협력사 및 담당자 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 협력사 및 담당자 정보
+
+ </th>
+
+ </tr>
+
+ <tr>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 협력사 코드
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 25%;
+
+ "
+
+ >
+
+ 협력사명
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 담당자
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 전화번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 20%;
+
+ "
+
+ >
+
+ 이메일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 비고
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 (반복 영역) -->
+
+ <tr>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사코드}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명}}</td>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_담당자}}</td>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{비고}}</td>
+
+ </tr>
+
+ <!-- /데이터 행 -->
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 4. 계약 대상 자재 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="15"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 대상 자재 정보 (총 {{대상_자재_수}}건 - 결재본문 내 표시 자재는 100건 이하로 제한되어 있습니다)
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 순번
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 6%;
+
+ "
+
+ >
+
+ 플랜트
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 6%;
+
+ "
+
+ >
+
+ 프로젝트
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 8%;
+
+ "
+
+ >
+
+ 자재그룹
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 자재그룹명
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 자재번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 자재상세
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 연간단가 여부
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 수량
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 구매단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 계약단가
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 수량단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 총중량
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 중량단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 계약금액
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 (반복 영역) -->
+
+ <tr style="font-size: 12px;">
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_1}}</td>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_2}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_2}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_2}}</td>
+
+ </tr>
+
+ <!-- /데이터 행 -->
+
+ <tr>
+
+ <td colspan="14" style="background-color: #f5f5f5; padding: 8px 10px; text-align: center; font-weight: 700; border: 1px solid #ccc;">총 계약 금액</td>
+
+ <td style="padding: 8px 10px; text-align: right; font-weight: 700; border: 1px solid #ccc;">{{총_계약금액}}</td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 5. 보증 내용 -->
+
+ <!-- <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="10"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 보증 내용
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 구분
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 차수
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 증권번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 7%;
+
+ "
+
+ >
+
+ 보증금율(%)
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 13%;
+
+ "
+
+ >
+
+ 보증 금액
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증 기간
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증기간 시작일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증기간 종료일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 발행 기관
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 발행<br>비고<br>지
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 계약보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_비고_1}}</td>
+
+ </tr>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 지급보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_비고_1}}</td>
+
+ </tr>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 하자보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_비고_1}}</td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+-->
+
+
+
+ <!-- 6. 하도급 자율점검 Check List -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="12"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 하도급 자율점검 Check List
+
+ </th>
+
+ </tr>
+
+ <!-- 헤더 1행: 계약 시 -->
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ colspan="12"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 계약 시 [계약체결 단계]
+
+ </th>
+
+ </tr>
+
+ <!-- 헤더 2행 ~ 4행 (복합 구조) -->
+
+ <tr style="font-size: 12px;">
+
+ <!-- 작업 前 서면발급 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 작업 前<br>서면발급
+
+ </th>
+
+ <!-- 1. 계약서면 발급 -->
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 30%;
+
+ "
+
+ >
+
+ 1. 계약서면 발급
+
+ </th>
+
+ <!-- 2. 부당 하도급 대금 결정 행위 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 2. 부당하도급대<br>금 결정 행위<br>(대금결정방법)
+
+ </th>
+
+ <!-- 점검 결과 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 점검결과<br>"준수"<br>"위반"<br>"위반의심"
+
+ </th>
+
+ <!-- 위반/위반의심 시 작성 항목 -->
+
+ <th
+
+ colspan="3"
+
+ rowspan="2"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 45%;
+
+ "
+
+ >
+
+ 위반/위반의심 시 a~c 작성 欄
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <!-- 6대 법정 기재사항 -->
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 6대 법정 기재사항 명기 여부
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <!-- 1~6 항목 -->
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ①위탁일자<br>/위탁내용
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ②인도시기<br>/장소
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ③검사방법<br>/시기
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ④대금지급<br>방법/기일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ⑤원재료지급<br>방법/기일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ⑥원재료가격변동<br>에 따른 대금조정 등
+
+ </th>
+
+ <!-- a, b, c -->
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ a. 귀책부서
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ b. 원인
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ c. 대책
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 -->
+
+ <tr style="font-size: 12px;">
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{작업전_서면발급_체크}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_1}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_2}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_3}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_4}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_5}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_6}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{부당대금_결정}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{점검결과}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{귀책부서}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{원인}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{대책}}
+
+ </td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+</div>
+
diff --git a/lib/basic-contract/cpvw-service.ts b/lib/basic-contract/cpvw-service.ts
new file mode 100644
index 00000000..6d249002
--- /dev/null
+++ b/lib/basic-contract/cpvw-service.ts
@@ -0,0 +1,236 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// CPVW_WAB_QUST_LIST_VIEW 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요)
+export interface CPVWWabQustListView {
+ [key: string]: string | number | Date | null | undefined
+}
+
+// 테스트 환경용 폴백 데이터 (실제 CPVW_WAB_QUST_LIST_VIEW 테이블 구조에 맞춤)
+const FALLBACK_TEST_DATA: CPVWWabQustListView[] = [
+ {
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ }
+]
+
+const normalizeOracleRows = (rows: Array<Record<string, unknown>>): CPVWWabQustListView[] => {
+ return rows.map((item) => {
+ const convertedItem: CPVWWabQustListView = {}
+ for (const [key, value] of Object.entries(item)) {
+ if (value instanceof Date) {
+ convertedItem[key] = value
+ } else if (value === null) {
+ convertedItem[key] = null
+ } else {
+ convertedItem[key] = String(value)
+ }
+ }
+ return convertedItem
+ })
+}
+
+/**
+ * CPVW_WAB_QUST_LIST_VIEW 테이블 전체 조회
+ * @returns 테이블 데이터 배열
+ */
+export async function getCPVWWabQustListViewData(): Promise<{
+ success: boolean
+ data: CPVWWabQustListView[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCPVWWabQustListViewData] CPVW_WAB_QUST_LIST_VIEW 테이블 조회 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE ROWNUM < 100
+ ORDER BY 1
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getCPVWWabQustListViewData] 조회 성공 - ${rows.length}건`)
+
+ // 데이터 타입 변환 (필요에 따라 조정)
+ const cleanedResult = normalizeOracleRows(rows)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCPVWWabQustListViewData] 오류:', error)
+ console.log('🔄 [getCPVWWabQustListViewData] 폴백 테스트 데이터 사용')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
+export async function getCPVWWabQustListViewByRegNo(regNo: string): Promise<{
+ success: boolean
+ data?: CPVWWabQustListView
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ if (!regNo) {
+ return {
+ success: false,
+ error: 'REG_NO는 필수입니다.'
+ }
+ }
+
+ try {
+ console.log(`[getCPVWWabQustListViewByRegNo] REG_NO=${regNo} 조회`)
+ const result = await oracleKnex.raw(
+ `
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE REG_NO = :regNo
+ `,
+ { regNo }
+ )
+
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+ const cleanedResult = normalizeOracleRows(rows)
+
+ if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getCPVWWabQustListViewByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: '해당 REG_NO에 대한 데이터가 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ data: cleanedResult[0],
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('[getCPVWWabQustListViewByRegNo] 오류:', error)
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 6f4e5d53..12278c54 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -2862,6 +2862,10 @@ export async function requestLegalReviewAction(
}
}
+// ⚠️ SSLVW(법무관리시스템) PRGS_STAT_DSC 문자열을 그대로 저장하는 함수입니다.
+// - 상태 텍스트 및 완료 여부는 외부 시스템에 의존하므로 신뢰도가 100%는 아니고,
+// - 여기에서 관리하는 값들은 UI 표시/참고용으로만 사용해야 합니다.
+// - 최종 승인 차단 등 핵심 비즈니스 로직에서는 SSLVW 쪽 완료 시간을 직접 신뢰하지 않습니다.
const persistLegalReviewStatus = async ({
contractId,
regNo,
@@ -2904,6 +2908,121 @@ const persistLegalReviewStatus = async ({
}
/**
+ * 준법문의 요청 서버 액션
+ */
+export async function requestComplianceInquiryAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string }> {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContractView.id,
+ complianceReviewRequestedAt: basicContractView.complianceReviewRequestedAt,
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 준법문의 요청 가능한 계약서 필터링 (이미 요청되지 않은 것만)
+ const eligibleContracts = contracts.filter(contract =>
+ !contract.complianceReviewRequestedAt
+ )
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "준법문의 요청 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ for (const contract of eligibleContracts) {
+ await tx
+ .update(basicContract)
+ .set({
+ complianceReviewRequestedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(basicContract.id, contract.id))
+ }
+ })
+
+ revalidateTag("basic-contracts")
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 준법문의 요청이 완료되었습니다.`
+ }
+}
+
+/**
+ * 준법문의 상태 저장 (준법문의 전용 필드 사용)
+ */
+const persistComplianceReviewStatus = async ({
+ contractId,
+ regNo,
+ progressStatus,
+}: {
+ contractId: number
+ regNo: string
+ progressStatus: string
+}) => {
+ const now = new Date()
+
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ // ⚠️ CPVW PRGS_STAT_DSC 문자열을 기반으로 한 best-effort 휴리스틱입니다.
+ // - 외부 시스템의 상태 텍스트에 의존하므로 신뢰도가 100%는 아니고,
+ // - 여기에서 설정하는 완료 시간(complianceReviewCompletedAt)은 UI 표시용으로만 사용해야 합니다.
+ // - 버튼 활성화, 서버 액션 차단, 필터 조건 등 핵심 비즈니스 로직에서는
+ // 이 값을 신뢰하지 않도록 합니다.
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ const isCompleted = progressStatus && (
+ progressStatus.includes('완료') ||
+ progressStatus.includes('승인') ||
+ progressStatus.includes('종료')
+ )
+
+ await db.transaction(async (tx) => {
+ // 준법문의 상태 업데이트 (준법문의 전용 필드 사용)
+ const updateData: any = {
+ complianceReviewRegNo: regNo,
+ complianceReviewProgressStatus: progressStatus,
+ updatedAt: now,
+ }
+
+ // 완료 상태인 경우 완료일 설정
+ if (isCompleted) {
+ updateData.complianceReviewCompletedAt = now
+ }
+
+ await tx
+ .update(basicContract)
+ .set(updateData)
+ .where(eq(basicContract.id, contractId))
+ })
+
+ revalidateTag("basic-contracts")
+}
+
+/**
* SSLVW 데이터로부터 법무검토 상태 업데이트
* @param sslvwData 선택된 SSLVW 데이터 배열
* @param selectedContractIds 선택된 계약서 ID 배열
@@ -3033,6 +3152,137 @@ export async function updateLegalReviewStatusFromSSLVW(
}
}
+/**
+ * CPVW 데이터로부터 준법문의 상태 업데이트
+ * @param cpvwData 선택된 CPVW 데이터 배열
+ * @param selectedContractIds 선택된 계약서 ID 배열
+ * @returns 성공 여부 및 메시지
+ */
+export async function updateComplianceReviewStatusFromCPVW(
+ cpvwData: Array<{ REG_NO?: string; reg_no?: string; PRGS_STAT_DSC?: string; prgs_stat_dsc?: string; [key: string]: any }>,
+ selectedContractIds: number[]
+): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> {
+ try {
+ console.log(`[updateComplianceReviewStatusFromCPVW] CPVW 데이터로부터 준법문의 상태 업데이트 시작`)
+
+ if (!cpvwData || cpvwData.length === 0) {
+ return {
+ success: false,
+ message: 'CPVW 데이터가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!selectedContractIds || selectedContractIds.length === 0) {
+ return {
+ success: false,
+ message: '선택된 계약서가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (selectedContractIds.length !== 1) {
+ return {
+ success: false,
+ message: '한 개의 계약서만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (cpvwData.length !== 1) {
+ return {
+ success: false,
+ message: '준법문의 시스템 데이터도 한 건만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contractId = selectedContractIds[0]
+ const cpvwItem = cpvwData[0]
+ const regNo = String(
+ cpvwItem.REG_NO ??
+ cpvwItem.reg_no ??
+ cpvwItem.RegNo ??
+ ''
+ ).trim()
+ const progressStatus = String(
+ cpvwItem.PRGS_STAT_DSC ??
+ cpvwItem.prgs_stat_dsc ??
+ cpvwItem.PrgsStatDsc ??
+ ''
+ ).trim()
+
+ if (!regNo) {
+ return {
+ success: false,
+ message: 'REG_NO 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!progressStatus) {
+ return {
+ success: false,
+ message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contract = await db
+ .select({
+ id: basicContract.id,
+ complianceReviewRegNo: basicContract.complianceReviewRegNo,
+ })
+ .from(basicContract)
+ .where(eq(basicContract.id, contractId))
+ .limit(1)
+
+ if (!contract[0]) {
+ return {
+ success: false,
+ message: `계약서(${contractId})를 찾을 수 없습니다.`,
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (contract[0].complianceReviewRegNo && contract[0].complianceReviewRegNo !== regNo) {
+ console.warn(`[updateComplianceReviewStatusFromCPVW] REG_NO가 변경됩니다: ${contract[0].complianceReviewRegNo} -> ${regNo}`)
+ }
+
+ // 준법문의 상태 업데이트
+ await persistComplianceReviewStatus({
+ contractId,
+ regNo,
+ progressStatus,
+ })
+
+ console.log(`[updateComplianceReviewStatusFromCPVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`)
+
+ return {
+ success: true,
+ message: '준법문의 상태가 업데이트되었습니다.',
+ updatedCount: 1,
+ errors: []
+ }
+
+ } catch (error) {
+ console.error('[updateComplianceReviewStatusFromCPVW] 오류:', error)
+ return {
+ success: false,
+ message: '준법문의 상태 업데이트 중 오류가 발생했습니다.',
+ updatedCount: 0,
+ errors: [error instanceof Error ? error.message : '알 수 없는 오류']
+ }
+ }
+}
+
export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{
success: boolean
message: string
@@ -3274,12 +3524,9 @@ export async function processBuyerSignatureAction(
}
}
- if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
- return {
- success: false,
- message: "법무검토가 완료되지 않았습니다."
- }
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 최종승인을 막지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
// 파일 저장 로직 (기존 파일 덮어쓰기)
const saveResult = await saveBuffer({
@@ -3373,9 +3620,9 @@ export async function prepareFinalApprovalAction(
if (contract.completedAt !== null || !contract.signedFilePath) {
return false
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 필터링하지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
return true
})
@@ -3949,6 +4196,8 @@ export async function saveGtcDocumentAction({
buyerSignedAt: null,
legalReviewRequestedAt: null,
legalReviewCompletedAt: null,
+ complianceReviewRequestedAt: null,
+ complianceReviewCompletedAt: null,
updatedAt: new Date()
})
.where(eq(basicContract.id, documentId))
diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts
index 38ecb67d..08b43f82 100644
--- a/lib/basic-contract/sslvw-service.ts
+++ b/lib/basic-contract/sslvw-service.ts
@@ -10,18 +10,89 @@ export interface SSLVWPurInqReq {
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [
{
- id: 1,
- request_number: 'REQ001',
- status: 'PENDING',
- created_date: new Date('2025-01-01'),
- description: '테스트 요청 1'
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중이라고',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
},
{
- id: 2,
- request_number: 'REQ002',
- status: 'APPROVED',
- created_date: new Date('2025-01-02'),
- description: '테스트 요청 2'
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
}
]
@@ -89,6 +160,7 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
success: boolean
data?: SSLVWPurInqReq
error?: string
+ isUsingFallback?: boolean
}> {
if (!regNo) {
return {
@@ -112,6 +184,21 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
const cleanedResult = normalizeOracleRows(rows)
if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getSSLVWPurInqReqByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: '해당 REG_NO에 대한 데이터가 없습니다.'
@@ -120,10 +207,27 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
return {
success: true,
- data: cleanedResult[0]
+ data: cleanedResult[0],
+ isUsingFallback: false
}
} catch (error) {
console.error('[getSSLVWPurInqReqByRegNo] 오류:', error)
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index 575582cf..77e36bc7 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -18,9 +18,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW, updateComplianceReviewStatusFromCPVW, requestComplianceInquiryAction } from "../service"
import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog"
+import { CPVWWabQustListViewDialog } from "@/components/common/legal/cpvw-wab-qust-list-view-dialog"
import { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
@@ -81,47 +82,17 @@ export function BasicContractDetailTableToolbarActions({
if (contract.completedAt !== null || !contract.signedFilePath) {
return false;
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false;
- }
+ // ⚠️ 법무/준법문의 완료 여부는 SSLVW/CPVW 상태 및 완료 시간에 의존하므로,
+ // 여기서는 legalReviewCompletedAt / complianceReviewCompletedAt 기반으로
+ // 최종 승인 버튼을 막지 않습니다. (상태/시간은 UI 참고용으로만 사용)
return true;
});
- // 법무검토 요청 가능 여부
- // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR
- // 2. 협의 없음 (코멘트 없음, hasComments: false)
- // 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가
- const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => {
- const contract = row.original;
- // 이미 법무검토 요청된 계약서는 제외
- if (contract.legalReviewRequestedAt) {
- return false;
- }
- // 이미 최종승인 완료된 계약서는 제외
- if (contract.completedAt) {
- return false;
- }
-
- // 협의 완료된 경우 → 가능
- if (contract.negotiationCompletedAt) {
- return true;
- }
-
- // 협의 완료되지 않은 경우
- // GTC 템플릿인 경우 코멘트 존재 여부 확인
- if (contract.templateName?.includes('GTC')) {
- const contractGtcData = gtcData[contract.id];
- // 코멘트가 없으면 가능 (협의 없음)
- if (contractGtcData && !contractGtcData.hasComments) {
- return true;
- }
- // 코멘트가 있으면 불가 (협의 중)
- return false;
- }
-
- // GTC가 아닌 경우는 협의 완료 여부만 확인
- return false;
- });
+ // 법무검토 요청 가능 여부: 준법서약 템플릿이 아닐 때 항상 활성화
+ const canRequestLegalReview = !isComplianceTemplate;
+
+ // 준법문의 버튼 활성화 가능 여부: 준법서약 템플릿일 때 항상 활성화
+ const canRequestComplianceInquiry = isComplianceTemplate;
// 필터링된 계약서들 계산
const resendContracts = selectedRows.map(row => row.original)
@@ -394,6 +365,47 @@ export function BasicContractDetailTableToolbarActions({
}
}
+ // CPVW 데이터 선택 확인 핸들러
+ const handleCPVWConfirm = async (selectedCPVWData: any[]) => {
+ if (!selectedCPVWData || selectedCPVWData.length === 0) {
+ toast.error("선택된 데이터가 없습니다.")
+ return
+ }
+
+ if (selectedRows.length !== 1) {
+ toast.error("계약서 한 건을 선택해주세요.")
+ return
+ }
+
+ try {
+ setLoading(true)
+
+ // 선택된 계약서 ID들 추출
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ // 서버 액션 호출
+ const result = await updateComplianceReviewStatusFromCPVW(selectedCPVWData, selectedContractIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+
+ if (result.errors && result.errors.length > 0) {
+ toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`)
+ }
+
+ } catch (error) {
+ console.error('CPVW 확인 처리 실패:', error)
+ toast.error('준법문의 상태 업데이트 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
// 빠른 승인 (서명 없이)
const confirmQuickApproval = async () => {
setLoading(true)
@@ -541,11 +553,34 @@ export function BasicContractDetailTableToolbarActions({
const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx'
// 법무검토 요청 / 준법문의
- const handleRequestLegalReview = () => {
+ const handleRequestLegalReview = async () => {
if (isComplianceTemplate) {
+ // 준법문의: 선택된 계약서가 있으면 요청일 기록 후 외부 URL 열기, 없으면 URL만 열기
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ if (selectedContractIds.length > 0) {
+ try {
+ setLoading(true)
+ const result = await requestComplianceInquiryAction(selectedContractIds)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('준법문의 요청 처리 실패:', error)
+ toast.error('준법문의 요청 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 선택된 계약서가 있든 없든 URL은 항상 열기
window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer')
return
}
+ // 법무검토 요청: 선택된 행이 없어도 다이얼로그 열기
setLegalReviewDialog(true)
}
@@ -617,31 +652,64 @@ export function BasicContractDetailTableToolbarActions({
</span>
</Button>
- {/* 법무검토 버튼 (SSLVW 데이터 조회) */}
- <SSLVWPurInqReqDialog
- onConfirm={handleSSLVWConfirm}
- requireSingleSelection
- triggerDisabled={selectedRows.length !== 1 || loading}
- triggerTitle={
- selectedRows.length !== 1
- ? "계약서 한 건을 선택해주세요"
- : undefined
- }
- />
+ {/* 법무검토 버튼 (SSLVW 데이터 조회) - 준법서약 템플릿이 아닐 때만 표시 */}
+ {!isComplianceTemplate && (
+ <SSLVWPurInqReqDialog
+ onConfirm={handleSSLVWConfirm}
+ requireSingleSelection
+ triggerDisabled={selectedRows.length !== 1 || loading}
+ triggerTitle={
+ selectedRows.length !== 1
+ ? "계약서 한 건을 선택해주세요"
+ : undefined
+ }
+ />
+ )}
+
+ {/* 준법문의 요청 데이터 조회 버튼 (준법서약 템플릿만) */}
+ {isComplianceTemplate && (
+ <CPVWWabQustListViewDialog
+ onConfirm={handleCPVWConfirm}
+ requireSingleSelection
+ triggerDisabled={selectedRows.length !== 1 || loading}
+ triggerTitle={
+ selectedRows.length !== 1
+ ? "계약서 한 건을 선택해주세요"
+ : undefined
+ }
+ />
+ )}
{/* 법무검토 요청 / 준법문의 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleRequestLegalReview}
- className="gap-2"
- title={isComplianceTemplate ? "준법문의 링크로 이동" : "법무검토 요청 링크 선택"}
- >
- <FileText className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isComplianceTemplate ? "준법문의" : "법무검토 요청"}
- </span>
- </Button>
+ {isComplianceTemplate ? (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ disabled={loading}
+ title="준법문의 링크로 이동"
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 준법문의
+ </span>
+ </Button>
+ ) : (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ disabled={loading}
+ title="법무검토 요청 링크 선택"
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 법무검토 요청
+ </span>
+ </Button>
+ )}
{/* 최종승인 버튼 */}
<Button
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
index aab808b8..de6ba1a9 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -553,8 +553,8 @@ export function getDetailColumns({
minSize: 130,
},
- // 법무검토 상태
- {
+ // 법무검토 상태 (준법서약 템플릿이 아닐 때만 표시)
+ ...(!isComplianceTemplate ? [{
accessorKey: "legalReviewStatus",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="법무검토 상태" />
@@ -571,7 +571,30 @@ export function getDetailColumns({
return <div className="text-sm text-gray-400">-</div>
},
minSize: 140,
+ }] : []),
+
+ // 준법문의 상태 (준법서약 템플릿일 때만 표시)
+ ...(isComplianceTemplate ? [{
+ accessorKey: "complianceReviewStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="준법문의 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("complianceReviewStatus") as string | null
+
+ // PRGS_STAT_DSC 연동값 우선 표시
+ if (status) {
+ return <div className="text-sm text-gray-800">{status}</div>
+ }
+
+ // 동기화된 값이 없으면 빈 값 처리
+ return <div className="text-sm text-gray-400">-</div>
+ },
+ minSize: 140,
},
+ // Red Flag 컬럼들 (준법서약 템플릿일 때만 표시)
+ redFlagColumn,
+ redFlagResolutionColumn] : []),
// 계약완료일
{
@@ -659,17 +682,5 @@ export function getDetailColumns({
actionsColumn,
]
- // 준법서약 템플릿인 경우 Red Flag 컬럼과 해제 컬럼을 법무검토 상태 뒤에 추가
- if (isComplianceTemplate) {
- const legalReviewStatusIndex = baseColumns.findIndex((col) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (col as any).accessorKey === 'legalReviewStatus'
- })
-
- if (legalReviewStatusIndex !== -1) {
- baseColumns.splice(legalReviewStatusIndex + 1, 0, redFlagColumn, redFlagResolutionColumn)
- }
- }
-
return baseColumns
} \ No newline at end of file
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
index cface6b3..c6fe1cdd 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -241,6 +241,7 @@ type RedFlagResolutionState = {
<BasicContractDetailTableToolbarActions
table={table}
gtcData={gtcData}
+ agreementCommentData={agreementCommentData}
redFlagData={redFlagData}
redFlagResolutionData={redFlagResolutionData}
isComplianceTemplate={isComplianceTemplate}
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 4e7da36c..64dc3aa8 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -20,7 +20,7 @@ import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po-bidding"
import { getCurrentSAPDate } from "@/lib/soap/utils"
import { generateContractNumber } from "@/lib/general-contracts/service"
import { saveFile } from "@/lib/file-stroage"
-
+import { checkAndSaveChemicalSubstancesForBidding } from "./service"
// TO Contract
export async function transmitToContract(biddingId: number, userId: number) {
try {
@@ -125,6 +125,11 @@ export async function transmitToContract(biddingId: number, userId: number) {
const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType)
console.log('Generated contractNumber:', contractNumber)
+ // 연동제 여부 변환 (boolean -> Y/N)
+ const interlockingSystem = biddingCondition?.isPriceAdjustmentApplicable
+ ? 'Y'
+ : (biddingCondition?.isPriceAdjustmentApplicable === false ? 'N' : null)
+
// general-contract 생성 (발주비율 계산된 최종 금액 사용)
const contractResult = await db.insert(generalContracts).values({
contractNumber,
@@ -141,10 +146,13 @@ export async function transmitToContract(biddingId: number, userId: number) {
currency: biddingData.currency || 'KRW',
// 계약 조건 정보 추가
paymentTerm: biddingCondition?.paymentTerms || null,
+ paymentDelivery: biddingCondition?.paymentTerms || null, // 지급조건 (납품 지급조건)
taxType: biddingCondition?.taxConditions || 'V0',
deliveryTerm: biddingCondition?.incoterms || 'FOB',
shippingLocation: biddingCondition?.shippingPort || null,
dischargeLocation: biddingCondition?.destinationPort || null,
+ contractDeliveryDate: biddingCondition?.contractDeliveryDate || null, // 계약납기일
+ interlockingSystem: interlockingSystem, // 연동제 여부
registeredById: userId,
lastUpdatedById: userId,
}).returning({ id: generalContracts.id })
@@ -644,7 +652,7 @@ export async function cancelDisposalAction(
}
// 사용자 이름 조회 헬퍼 함수
-async function getUserNameById(userId: string): Promise<string> {
+export async function getUserNameById(userId: string): Promise<string> {
try {
const user = await db
.select({ name: users.name })
@@ -730,6 +738,26 @@ export async function openBiddingAction(biddingId: number) {
})
.where(eq(biddings.id, biddingId))
+ // 4. 화학물질 조회 실행 (비동기로 실행해서 개찰 성능에 영향 없도록)
+ try {
+ // 개찰 트랜잭션이 완료된 후 화학물질 조회 시작
+ setImmediate(async () => {
+ try {
+ const result = await checkAndSaveChemicalSubstancesForBidding(biddingId)
+ if (result.success) {
+ console.log(`입찰 ${biddingId} 화학물질 조회 완료: ${result.results.filter(r => r.success).length}/${result.results.length}개 업체`)
+ } else {
+ console.error(`입찰 ${biddingId} 화학물질 조회 실패:`, result.message)
+ }
+ } catch (error) {
+ console.error(`입찰 ${biddingId} 화학물질 조회 중 오류:`, error)
+ }
+ })
+ } catch (error) {
+ // 화학물질 조회 실패해도 개찰은 성공으로 처리
+ console.error('화학물질 조회 시작 실패:', error)
+ }
+
return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' }
})
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 3d07d49c..b4f6f297 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -81,6 +81,7 @@ export async function prepareBiddingApprovalData(data: {
projectName: biddings.projectName,
itemName: biddings.itemName,
biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
submissionStartDate: biddings.submissionStartDate,
@@ -166,6 +167,7 @@ export async function prepareBiddingApprovalData(data: {
...bidding,
projectName: bidding.projectName || undefined,
itemName: bidding.itemName || undefined,
+ awardCount: bidding.awardCount || undefined,
bidPicName: bidding.bidPicName || undefined,
supplyPicName: bidding.supplyPicName || undefined,
targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined,
@@ -264,12 +266,14 @@ export async function requestBiddingInvitationWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(data.currentUser.id.toString());
+
await db
.update(biddings)
.set({
status: 'approval_pending', // 결재 진행중 상태
- updatedBy: String(data.currentUser.id), // id를 string으로 변환
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -463,6 +467,7 @@ export async function requestBiddingClosureWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
+ const { getUserNameById } = await import('@/lib/bidding/actions');
// 유찰상태인지 확인
const biddingResult = await db
@@ -485,12 +490,12 @@ export async function requestBiddingClosureWithApproval(data: {
// 3. 입찰 상태를 결재 진행중으로 변경
debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작');
-
+ const userName = await getUserNameById(data.currentUser.id.toString());
await db
.update(biddings)
.set({
status: 'approval_pending', // 폐찰 결재 진행중 상태
- updatedBy: Number(data.currentUser.id),
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -691,12 +696,13 @@ export async function requestBiddingAwardWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(data.currentUser.id.toString());
await db
.update(biddings)
.set({
status: 'approval_pending', // 낙찰 결재 진행중 상태
- updatedBy: Number(data.currentUser.id),
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index f52ecb1e..eec3f253 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc, inArray } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -30,43 +30,113 @@ async function getUserNameById(userId: string): Promise<string> {
// 데이터 조회 함수들
export interface BiddingDetailData {
bidding: Awaited<ReturnType<typeof getBiddingById>>
- quotationDetails: QuotationDetails | null
+ quotationDetails: null
quotationVendors: QuotationVendor[]
- prItems: Awaited<ReturnType<typeof getPRItemsForBidding>>
+ prItems: Awaited<ReturnType<typeof getPrItemsForBidding>>
}
// getBiddingById 함수 임포트 (기존 함수 재사용)
import { getBiddingById, updateBiddingProjectInfo } from '@/lib/bidding/service'
+import { getPrItemsForBidding } from '../pre-quote/service'
-// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용)
+// Bidding Detail Data 조회 (캐시 제거, 로직 단순화)
export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> {
- return unstable_cache(
- async () => {
- const [
- bidding,
- quotationDetails,
- quotationVendors,
- prItems
- ] = await Promise.all([
- getBiddingById(biddingId),
- getQuotationDetails(biddingId),
- getQuotationVendors(biddingId),
- getPRItemsForBidding(biddingId)
- ])
+ try {
+ // 1. 입찰 정보 조회
+ const bidding = await getBiddingById(biddingId)
- return {
- bidding,
- quotationDetails,
- quotationVendors,
- prItems
+ // 2. 입찰 품목 조회 (pre-quote service 함수 재사용)
+ const prItems = await getPrItemsForBidding(biddingId)
+
+ // 3. 본입찰 제출 업체 조회 (bidding_submitted 상태)
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ vendorId: biddingCompanies.companyId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'`,
+ submissionDate: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ awardRatio: biddingCompanies.awardRatio,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ invitationStatus: biddingCompanies.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: biddingCompanies.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: biddingCompanies.priceAdjustmentNote,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+ // Contact info from biddingCompaniesContacts
+ contactPerson: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactPhone: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompanies)
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddingCompaniesContacts, and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, biddingCompanies.companyId)
+ ))
+ .leftJoin(companyConditionResponses, and(
+ eq(companyConditionResponses.biddingCompanyId, biddingCompanies.id),
+ eq(companyConditionResponses.isPreQuote, false) // 본입찰 데이터만
+ ))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true)
+ ))
+ .orderBy(desc(biddingCompanies.finalQuoteAmount))
+
+ // 중복 제거 (업체당 여러 담당자가 있을 경우 첫 번째만 사용하거나 처리)
+ // 여기서는 간단히 메모리에서 중복 제거 (biddingCompanyId 기준)
+ const uniqueVendors = vendorsData.reduce((acc, curr) => {
+ if (!acc.find(v => v.id === curr.id)) {
+ acc.push({
+ id: curr.id,
+ biddingId: curr.biddingId,
+ vendorId: curr.vendorId,
+ vendorName: curr.vendorName || `Vendor ${curr.vendorId}`,
+ vendorCode: curr.vendorCode || '',
+ vendorEmail: curr.vendorEmail || '',
+ contactPerson: curr.contactPerson || '',
+ contactEmail: curr.contactEmail || '',
+ contactPhone: curr.contactPhone || '',
+ quotationAmount: Number(curr.quotationAmount) || 0,
+ currency: curr.currency,
+ submissionDate: curr.submissionDate ? (curr.submissionDate instanceof Date ? curr.submissionDate.toISOString().split('T')[0] : String(curr.submissionDate).split('T')[0]) : '',
+ isWinner: curr.isWinner,
+ awardRatio: curr.awardRatio ? Number(curr.awardRatio) : null,
+ isBiddingParticipated: curr.isBiddingParticipated,
+ invitationStatus: curr.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: curr.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: curr.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: curr.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: curr.priceAdjustmentNote,
+ hasChemicalSubstance: curr.hasChemicalSubstance,
+ documents: [],
+ })
}
- },
- [`bidding-detail-data-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'pr-items']
+ return acc
+ }, [] as QuotationVendor[])
+
+ return {
+ bidding,
+ quotationDetails: null,
+ quotationVendors: uniqueVendors,
+ prItems
}
- )()
+ } catch (error) {
+ console.error('Failed to get bidding detail data:', error)
+ throw error
+ }
}
+
+// QuotationDetails Interface (Keeping it for type safety if needed elsewhere, or remove if safe)
export interface QuotationDetails {
biddingId: number
estimatedPrice: number // 예상액
@@ -94,6 +164,12 @@ export interface QuotationVendor {
awardRatio: number | null // 발주비율
isBiddingParticipated: boolean | null // 본입찰 참여여부
invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: boolean | null // SHI가 요청한 연동제 요청 여부
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 (companyConditionResponses.priceAdjustmentResponse)
+ shiPriceAdjustmentApplied: boolean | null // SHI 연동제 적용여부
+ priceAdjustmentNote: string | null // 연동제 Note
+ hasChemicalSubstance: boolean | null // 화학물질여부
documents: Array<{
id: number
fileName: string
@@ -103,66 +179,6 @@ export interface QuotationVendor {
}>
}
-// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 (캐시 적용)
-export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> {
- return unstable_cache(
- async () => {
- try {
- // bidding_companies 테이블에서 견적 데이터를 집계
- const quotationStats = await db
- .select({
- biddingId: biddingCompanies.biddingId,
- estimatedPrice: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('estimated_price'),
- lowestQuote: sql<number>`MIN(${biddingCompanies.finalQuoteAmount})`.as('lowest_quote'),
- averageQuote: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('average_quote'),
- targetPrice: sql<number>`AVG(${biddings.targetPrice})`.as('target_price'),
- quotationCount: sql<number>`COUNT(*)`.as('quotation_count'),
- lastUpdated: sql<string>`MAX(${biddingCompanies.updatedAt})`.as('last_updated')
- })
- .from(biddingCompanies)
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL`
- ))
- .groupBy(biddingCompanies.biddingId)
- .limit(1)
-
- if (quotationStats.length === 0) {
- return {
- biddingId,
- estimatedPrice: 0,
- lowestQuote: 0,
- averageQuote: 0,
- targetPrice: 0,
- quotationCount: 0,
- lastUpdated: new Date().toISOString()
- }
- }
-
- const stat = quotationStats[0]
-
- return {
- biddingId,
- estimatedPrice: Number(stat.estimatedPrice) || 0,
- lowestQuote: Number(stat.lowestQuote) || 0,
- averageQuote: Number(stat.averageQuote) || 0,
- targetPrice: Number(stat.targetPrice) || 0,
- quotationCount: Number(stat.quotationCount) || 0,
- lastUpdated: stat.lastUpdated || new Date().toISOString()
- }
- } catch (error) {
- console.error('Failed to get quotation details:', error)
- return null
- }
- },
- [`quotation-details-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-details']
- }
- )()
-}
-
// bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회
export async function getBiddingCompaniesData(biddingId: number) {
try {
@@ -281,7 +297,7 @@ export async function getAllBiddingCompanies(biddingId: number) {
}
}
-// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh)
+// prItemsForBidding 테이블에서 품목 정보 조회 (deprecated - import from pre-quote/service)
export async function getPRItemsForBidding(biddingId: number) {
try {
const items = await db
@@ -297,70 +313,9 @@ export async function getPRItemsForBidding(biddingId: number) {
}
}
-// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용)
-export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> {
- return unstable_cache(
- async () => {
- try {
- // bidding_companies 테이블을 메인으로 vendors를 조인하여 협력업체 정보 조회
- const vendorsData = await db
- .select({
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- vendorId: biddingCompanies.companyId,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- vendorEmail: vendors.email, // 벤더의 기본 이메일
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- quotationAmount: biddingCompanies.finalQuoteAmount,
- currency: sql<string>`'KRW'`,
- submissionDate: biddingCompanies.finalQuoteSubmittedAt,
- isWinner: biddingCompanies.isWinner,
- // awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
- awardRatio: biddingCompanies.awardRatio,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- invitationStatus: biddingCompanies.invitationStatus,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true) // 본입찰 선정된 업체만 조회
- ))
- .orderBy(desc(biddingCompanies.finalQuoteAmount))
+// 견적 시스템에서 협력업체 정보를 가져오는 함수 (Deprecated - integrated into getBiddingDetailData)
+// export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { ... }
- return vendorsData.map(vendor => ({
- id: vendor.id,
- biddingId: vendor.biddingId,
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
- vendorCode: vendor.vendorCode || '',
- vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일
- contactPerson: vendor.contactPerson || '',
- contactEmail: vendor.contactEmail || '',
- contactPhone: vendor.contactPhone || '',
- quotationAmount: Number(vendor.quotationAmount) || 0,
- currency: vendor.currency,
- submissionDate: vendor.submissionDate ? (vendor.submissionDate instanceof Date ? vendor.submissionDate.toISOString().split('T')[0] : String(vendor.submissionDate).split('T')[0]) : '',
- isWinner: vendor.isWinner,
- awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
- isBiddingParticipated: vendor.isBiddingParticipated,
- invitationStatus: vendor.invitationStatus,
- documents: [], // 빈 배열로 초기화
- }))
- } catch (error) {
- console.error('Failed to get quotation vendors:', error)
- return []
- }
- },
- [`quotation-vendors-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors']
- }
- )()
-}
// 사전견적 데이터 조회 (내정가 산정용)
export async function getPreQuoteData(biddingId: number) {
@@ -898,11 +853,59 @@ export async function registerBidding(biddingId: number, userId: string) {
await db.transaction(async (tx) => {
debugLog('registerBidding: Transaction started')
- // 1. 입찰 상태를 오픈으로 변경
+
+ // 0. 입찰서 제출기간 계산 (입력값 절대 기준)
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding
+
+ let calculatedStartDate = bidding.submissionStartDate
+ let calculatedEndDate = bidding.submissionEndDate
+
+ if (submissionStartOffset !== null && submissionDurationDays !== null) {
+ // DB에 저장된 시간을 숫자 그대로 가져옴 (예: 10:00 저장 → 10 반환)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 }
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 }
+
+ // 서버의 오늘 날짜(년/월/일)를 그대로 사용해 00:00 UTC 시점 생성
+ const now = new Date()
+ const baseDate = new Date(Date.UTC(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ 0, 0, 0
+ ))
+
+ // 시작일 = baseDate + offset일 + 입력 시간(숫자 그대로)
+ const tempStartDate = new Date(baseDate)
+ tempStartDate.setUTCDate(tempStartDate.getUTCDate() + submissionStartOffset)
+ tempStartDate.setUTCHours(startTime.hours, startTime.minutes, 0, 0)
+
+ // 마감일 = 시작일 날짜만 기준 + duration일 + 입력 마감 시간
+ const tempEndDate = new Date(tempStartDate)
+ tempEndDate.setUTCHours(0, 0, 0, 0)
+ tempEndDate.setUTCDate(tempEndDate.getUTCDate() + submissionDurationDays)
+ tempEndDate.setUTCHours(endTime.hours, endTime.minutes, 0, 0)
+
+ calculatedStartDate = tempStartDate
+ calculatedEndDate = tempEndDate
+
+ debugLog('registerBidding: Submission dates calculated (Input Value Based)', {
+ baseDate: baseDate.toISOString(),
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ })
+ }
+
+ // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트
await tx
.update(biddings)
.set({
status: 'bidding_opened',
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
updatedBy: userName,
updatedAt: new Date()
})
@@ -1368,10 +1371,14 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: biddingCompanies.companyId,
companyName: vendors.vendorName,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio
+ awardRatio: biddingCompanies.awardRatio,
+ vendorCode: vendors.vendorCode,
+ companySize: vendors.businessSize,
+ targetPrice: biddings.targetPrice
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
.where(and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.isWinner, true)
@@ -1381,7 +1388,10 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: company.companyId,
companyName: company.companyName,
finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'),
- awardRatio: parseFloat(company.awardRatio?.toString() || '0')
+ awardRatio: parseFloat(company.awardRatio?.toString() || '0'),
+ vendorCode: company.vendorCode,
+ companySize: company.companySize,
+ targetPrice: company.targetPrice ? parseFloat(company.targetPrice.toString()) : 0
}))
} catch (error) {
console.error('Failed to get awarded companies:', error)
@@ -1410,7 +1420,7 @@ async function updateBiddingAmounts(biddingId: number) {
.set({
targetPrice: totalTargetAmount.toString(),
budget: totalBudgetAmount.toString(),
- finalBidPrice: totalActualAmount.toString(),
+ actualPrice: totalActualAmount.toString(),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -1693,7 +1703,7 @@ export interface PartnersBiddingListItem {
biddingNumber: string
originalBiddingNumber: string | null // 원입찰번호
revision: number | null
- projectName: string
+ projectName: string | null
itemName: string
title: string
contractType: string
@@ -1782,9 +1792,9 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
// 계산된 필드 추가
const resultWithCalculatedFields = result.map(item => ({
...item,
- respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : item.respondedAt.toString()) : null,
+ respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : String(item.respondedAt)) : null,
finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환
- finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : item.finalQuoteSubmittedAt.toString()) : null,
+ finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : String(item.finalQuoteSubmittedAt)) : null,
responseDeadline: item.submissionStartDate
? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전
: null,
@@ -1825,7 +1835,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
biddingRegistrationDate: biddings.biddingRegistrationDate,
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
// 가격 정보
currency: biddings.currency,
@@ -2596,101 +2605,72 @@ export async function getBiddingDocumentsForPartners(biddingId: number) {
// 입찰가 비교 분석 함수들
// =================================================
-// 벤더별 입찰가 정보 조회 (캐시 적용)
+// 벤더별 입찰가 정보 조회 (최적화 및 간소화됨)
export async function getVendorPricesForBidding(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- // 각 회사의 입찰가 정보를 조회 - 본입찰 참여 업체들
- const vendorPrices = await db
- .select({
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- biddingCompanyId: biddingCompanies.id,
- currency: sql<string>`'KRW'`, // 기본값 KRW
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만
- ))
+ try {
+ // 1. 본입찰 참여 업체들 조회
+ const participatingVendors = await db
+ .select({
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ biddingCompanyId: biddingCompanies.id,
+ currency: sql<string>`'KRW'`, // 기본값 KRW
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true) // 본입찰 참여 업체만
+ ))
- console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`)
+ if (participatingVendors.length === 0) {
+ return []
+ }
- const result: any[] = []
+ const biddingCompanyIds = participatingVendors.map(v => v.biddingCompanyId)
- for (const vendor of vendorPrices) {
- try {
- // 해당 회사의 품목별 입찰가 조회 (본입찰 데이터)
- const itemPrices = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- itemName: prItemsForBidding.itemInfo, // itemInfo 사용
- itemNumber: prItemsForBidding.itemNumber, // itemNumber도 포함
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- weight: prItemsForBidding.totalWeight, // totalWeight 사용
- weightUnit: prItemsForBidding.weightUnit,
- unitPrice: companyPrItemBids.bidUnitPrice,
- amount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(and(
- eq(companyPrItemBids.biddingCompanyId, vendor.biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
- ))
- .orderBy(prItemsForBidding.id)
-
- console.log(`Vendor ${vendor.companyName}: Found ${itemPrices.length} item prices`)
-
- // 총 금액은 biddingCompanies.finalQuoteAmount 사용
- const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
-
- result.push({
- companyId: vendor.companyId,
- companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
- biddingCompanyId: vendor.biddingCompanyId,
- totalAmount,
- currency: vendor.currency,
- itemPrices: itemPrices.map(item => ({
- prItemId: item.prItemId,
- itemName: item.itemName || item.itemNumber || `Item ${item.prItemId}`,
- quantity: parseFloat(item.quantity || '0'),
- quantityUnit: item.quantityUnit || 'ea',
- weight: item.weight ? parseFloat(item.weight) : null,
- weightUnit: item.weightUnit,
- unitPrice: parseFloat(item.unitPrice || '0'),
- amount: parseFloat(item.amount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate ?
- (typeof item.proposedDeliveryDate === 'string'
- ? item.proposedDeliveryDate
- : item.proposedDeliveryDate.toISOString().split('T')[0])
- : null,
- }))
- })
- } catch (vendorError) {
- console.error(`Error processing vendor ${vendor.companyId}:`, vendorError)
- // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리
- }
- }
+ // 2. 해당 업체들의 입찰 품목 조회 (한 번의 쿼리로 최적화)
+ // 필요한 필드만 조회: prItemId, bidUnitPrice, bidAmount
+ const allItemBids = await db
+ .select({
+ biddingCompanyId: companyPrItemBids.biddingCompanyId,
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ inArray(companyPrItemBids.biddingCompanyId, biddingCompanyIds),
+ eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
+ ))
- return result
- } catch (error) {
- console.error('Failed to get vendor prices for bidding:', error)
- return []
+ // 3. 업체별로 데이터 매핑
+ const result = participatingVendors.map(vendor => {
+ const vendorItems = allItemBids.filter(item => item.biddingCompanyId === vendor.biddingCompanyId)
+
+ const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
+
+ return {
+ companyId: vendor.companyId,
+ companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
+ biddingCompanyId: vendor.biddingCompanyId,
+ totalAmount,
+ currency: vendor.currency,
+ itemPrices: vendorItems.map(item => ({
+ prItemId: item.prItemId,
+ unitPrice: parseFloat(item.bidUnitPrice || '0'),
+ amount: parseFloat(item.bidAmount || '0'),
+ }))
}
- },
- [`bidding-vendor-prices-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items']
- }
- )()
+ })
+
+ return result
+ } catch (error) {
+ console.error('Failed to get vendor prices for bidding:', error)
+ return []
+ }
}
// 사양설명회 참여 여부 업데이트
@@ -2720,3 +2700,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num
return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' }
}
}
+
+// 연동제 정보 업데이트
+export async function updatePriceAdjustmentInfo(params: {
+ biddingCompanyId: number
+ shiPriceAdjustmentApplied: boolean | null
+ priceAdjustmentNote: string | null
+ hasChemicalSubstance: boolean | null
+}): Promise<{ success: boolean; error?: string }> {
+ try {
+ const result = await db.update(biddingCompanies)
+ .set({
+ shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: params.priceAdjustmentNote,
+ hasChemicalSubstance: params.hasChemicalSubstance,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, params.biddingCompanyId))
+ .returning({ biddingId: biddingCompanies.biddingId })
+
+ if (result.length > 0) {
+ const biddingId = result[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update price adjustment info:', error)
+ return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' }
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 5368b287..05c1a93d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -31,6 +31,7 @@ interface GetVendorColumnsProps {
}
export function getBiddingDetailVendorColumns({
+ onViewPriceAdjustment,
onViewItemDetails,
onSendBidding,
onUpdateParticipation,
@@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({
),
},
{
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제 응답',
+ cell: ({ row }) => {
+ const vendor = row.original
+ const response = vendor.priceAdjustmentResponse
+
+ // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기
+ const getBadgeVariant = () => {
+ if (response === null || response === undefined) return 'outline'
+ return response ? 'default' : 'secondary'
+ }
+
+ const getBadgeClass = () => {
+ if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer'
+ if (response === false) return 'hover:bg-gray-300 cursor-pointer'
+ return ''
+ }
+
+ const getLabel = () => {
+ if (response === null || response === undefined) return '해당없음'
+ return response ? '예' : '아니오'
+ }
+
+ return (
+ <Badge
+ variant={getBadgeVariant()}
+ className={getBadgeClass()}
+ onClick={() => onViewPriceAdjustment?.(vendor)}
+ >
+ {getLabel()}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'shiPriceAdjustmentApplied',
+ header: 'SHI연동제적용',
+ cell: ({ row }) => {
+ const applied = row.original.shiPriceAdjustmentApplied
+ if (applied === null || applied === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}>
+ {applied ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentNote',
+ header: '연동제 Note',
+ cell: ({ row }) => {
+ const note = row.original.priceAdjustmentNote
+ return (
+ <div className="text-sm max-w-[150px] truncate" title={note || ''}>
+ {note || '-'}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: 'hasChemicalSubstance',
+ header: '화학물질',
+ cell: ({ row }) => {
+ const hasChemical = row.original.hasChemicalSubstance
+ if (hasChemical === null || hasChemical === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={hasChemical ? 'destructive' : 'secondary'}>
+ {hasChemical ? '해당' : '해당없음'}
+ </Badge>
+ )
+ },
+ },
+ {
id: 'actions',
header: '작업',
cell: ({ row }) => {
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index fffac0c1..407cc51c 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
import { BiddingAwardDialog } from './bidding-award-dialog'
import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
-import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
import { Bidding } from '@/db/schema'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
@@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps {
onOpenSelectionReasonDialog: () => void
onViewItemDetails?: (vendor: QuotationVendor) => void
onViewQuotationHistory?: (vendor: QuotationVendor) => void
+ readOnly?: boolean
}
const filterFields: DataTableFilterField<QuotationVendor>[] = [
@@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({
vendors,
onRefresh,
onViewItemDetails,
- onViewQuotationHistory
+ onViewQuotationHistory,
+ readOnly = false
}: BiddingDetailVendorTableContentProps) {
const { data: session } = useSession()
const { toast } = useToast()
@@ -96,8 +98,7 @@ export function BiddingDetailVendorTableContent({
const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false)
const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
@@ -114,28 +115,9 @@ export function BiddingDetailVendorTableContent({
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
- const handleViewPriceAdjustment = async (vendor: QuotationVendor) => {
- try {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedVendor(vendor)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '연동제 정보 없음',
- description: '해당 업체의 연동제 정보가 없습니다.',
- variant: 'default',
- })
- }
- } catch (error) {
- console.error('Failed to load price adjustment form:', error)
- toast({
- title: '오류',
- description: '연동제 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
+ const handleViewPriceAdjustment = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsVendorPriceAdjustmentDialogOpen(true)
}
const handleViewQuotationHistory = async (vendor: QuotationVendor) => {
@@ -269,6 +251,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
winnerVendor={vendors.find(v => v.awardRatio === 100)}
singleSelectedVendor={singleSelectedVendor}
+ readOnly={readOnly}
/>
</DataTableAdvancedToolbar>
</DataTable>
@@ -296,11 +279,12 @@ export function BiddingDetailVendorTableContent({
}}
/>
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
+ <VendorPriceAdjustmentViewDialog
+ open={isVendorPriceAdjustmentDialogOpen}
+ onOpenChange={setIsVendorPriceAdjustmentDialogOpen}
vendorName={selectedVendor?.vendorName || ''}
+ priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null}
+ biddingCompanyId={selectedVendor?.id || 0}
/>
<QuotationHistoryDialog
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 8df29289..e934a5fe 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -5,13 +5,14 @@ import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react"
+import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react"
import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
+import { PriceAdjustmentDialog } from "./price-adjustment-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
import { QuotationVendor } from "@/lib/bidding/detail/service"
@@ -25,6 +26,7 @@ interface BiddingDetailVendorToolbarActionsProps {
onSuccess: () => void
winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더
singleSelectedVendor?: QuotationVendor | null // single select된 벤더
+ readOnly?: boolean
}
export function BiddingDetailVendorToolbarActions({
@@ -35,7 +37,8 @@ export function BiddingDetailVendorToolbarActions({
onOpenAwardRatioDialog,
onSuccess,
winnerVendor,
- singleSelectedVendor
+ singleSelectedVendor,
+ readOnly = false
}: BiddingDetailVendorToolbarActionsProps) {
const router = useRouter()
const { toast } = useToast()
@@ -47,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -82,53 +86,6 @@ export function BiddingDetailVendorToolbarActions({
setIsBiddingInvitationDialogOpen(true)
}
- // const handleBiddingInvitationSend = async (data: any) => {
- // try {
- // // 1. 기본계약 발송
- // const contractResult = await sendBiddingBasicContracts(
- // biddingId,
- // data.vendors,
- // data.generatedPdfs,
- // data.message
- // )
-
- // if (!contractResult.success) {
- // toast({
- // title: '기본계약 발송 실패',
- // description: contractResult.error,
- // variant: 'destructive',
- // })
- // return
- // }
-
- // // 2. 입찰 등록 진행
- // const registerResult = await registerBidding(bidding.id, userId)
-
- // if (registerResult.success) {
- // toast({
- // title: '본입찰 초대 완료',
- // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
- // })
- // setIsBiddingInvitationDialogOpen(false)
- // router.refresh()
- // onSuccess()
- // } else {
- // toast({
- // title: '오류',
- // description: registerResult.error,
- // variant: 'destructive',
- // })
- // }
- // } catch (error) {
- // console.error('본입찰 초대 실패:', error)
- // toast({
- // title: '오류',
- // description: '본입찰 초대에 실패했습니다.',
- // variant: 'destructive',
- // })
- // }
- // }
-
// 선정된 업체들 조회 (서버 액션 함수 사용)
const getSelectedVendors = async () => {
try {
@@ -165,27 +122,6 @@ export function BiddingDetailVendorToolbarActions({
})
}
- const handleRoundIncrease = () => {
- startTransition(async () => {
- const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- })
- router.push(`/evcp/bid`)
- onSuccess()
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: 'destructive',
- })
- }
- })
- }
-
const handleCancelAward = () => {
if (!winnerVendor) return
@@ -218,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({
title: "성공",
description: '차수증가가 완료되었습니다.',
})
- router.push(`/evcp/bid`)
- onSuccess()
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}/info`)
+ } else {
+ router.push(`/evcp/bid`)
+ }
+ // onSuccess()
} else {
toast({
title: "오류",
@@ -233,69 +173,87 @@ export function BiddingDetailVendorToolbarActions({
return (
<>
<div className="flex items-center gap-2">
- {/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsRoundIncreaseDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCw className="mr-2 h-4 w-4" />
- 차수증가
- </Button>
- )}
-
- {/* 발주비율 산정: single select 시에만 활성화 */}
- {(bidding.status === 'evaluation_of_bidding') && (
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenAwardRatioDialog}
- disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
- >
- <DollarSign className="mr-2 h-4 w-4" />
- 발주비율 산정
- </Button>
- )}
-
- {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */}
+ {!readOnly && (
<>
- <Button
- variant="destructive"
- size="sm"
- onClick={handleMarkAsDisposal}
- disabled={isPending}
- >
- <XCircle className="mr-2 h-4 w-4" />
- 유찰
- </Button>
- <Button
- variant="default"
- size="sm"
- onClick={onOpenAwardDialog}
- disabled={isPending}
- >
- <Trophy className="mr-2 h-4 w-4" />
- 낙찰
- </Button>
+ {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCw className="mr-2 h-4 w-4" />
+ 차수증가
+ </Button>
+ )}
+
+ {/* 발주비율 산정: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenAwardRatioDialog}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 발주비율 산정
+ </Button>
+ )}
+
+ {/* 연동제 적용여부: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsPriceAdjustmentDialogOpen(true)}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <Link2 className="mr-2 h-4 w-4" />
+ 연동제 적용
+ </Button>
+ )}
+
+ {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ <>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 유찰
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={onOpenAwardDialog}
+ disabled={isPending}
+ >
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰
+ </Button>
+ </>
+ )}
+
+ {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
+ {winnerVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelAwardDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 발주비율 취소
+ </Button>
+ )}
</>
)}
- {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
- {winnerVendor && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelAwardDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCcw className="mr-2 h-4 w-4" />
- 발주비율 취소
- </Button>
- )}
{/* 구분선 */}
{(bidding.status === 'bidding_generated' ||
bidding.status === 'bidding_disposal') && (
@@ -392,6 +350,14 @@ export function BiddingDetailVendorToolbarActions({
</DialogContent>
</Dialog>
+ {/* 연동제 적용여부 다이얼로그 */}
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ vendor={singleSelectedVendor || null}
+ onSuccess={onSuccess}
+ />
+
</>
)
}
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..96a3af0c
--- /dev/null
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { useToast } from "@/hooks/use-toast"
+import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Loader2 } from "lucide-react"
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendor: QuotationVendor | null
+ onSuccess: () => void
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ vendor,
+ onSuccess,
+}: PriceAdjustmentDialogProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 폼 상태
+ const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null)
+ const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("")
+ const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null)
+
+ // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화
+ React.useEffect(() => {
+ if (open && vendor) {
+ setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null)
+ setPriceAdjustmentNote(vendor.priceAdjustmentNote || "")
+ setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null)
+ }
+ }, [open, vendor])
+
+ const handleSubmit = async () => {
+ if (!vendor) return
+
+ setIsSubmitting(true)
+ try {
+ const result = await updatePriceAdjustmentInfo({
+ biddingCompanyId: vendor.id,
+ shiPriceAdjustmentApplied,
+ priceAdjustmentNote: priceAdjustmentNote || null,
+ hasChemicalSubstance,
+ })
+
+ if (result.success) {
+ toast({
+ title: "저장 완료",
+ description: "연동제 정보가 저장되었습니다.",
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("연동제 정보 저장 오류:", error)
+ toast({
+ title: "오류",
+ description: "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!vendor) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>연동제 적용 설정</DialogTitle>
+ <DialogDescription>
+ <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부를 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6 py-4">
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
+ <div className="space-y-0.5">
+ <Label className="text-base">업체 연동제 요청</Label>
+ <p className="text-sm text-muted-foreground">
+ 업체가 제출한 연동제 적용 요청 여부입니다.
+ </p>
+ </div>
+ <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}>
+ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div> */}
+
+ {/* SHI 연동제 적용여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">SHI 연동제 적용</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체에 연동제를 적용할지 결정합니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 미적용
+ </span>
+ <Switch
+ checked={shiPriceAdjustmentApplied === true}
+ onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)}
+ />
+ <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}>
+ 적용
+ </span>
+ </div>
+ </div>
+
+ {/* 연동제 Note */}
+ <div className="space-y-2">
+ <Label htmlFor="price-adjustment-note">연동제 Note</Label>
+ <Textarea
+ id="price-adjustment-note"
+ placeholder="연동제 관련 추가 사항을 입력하세요"
+ value={priceAdjustmentNote}
+ onChange={(e) => setPriceAdjustmentNote(e.target.value)}
+ rows={4}
+ />
+ </div>
+
+ {/* 화학물질 여부 */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">화학물질 해당여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체가 화학물질 취급 대상인지 여부입니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 해당없음
+ </span>
+ <Switch
+ checked={hasChemicalSubstance === true}
+ onCheckedChange={(checked) => setHasChemicalSubstance(checked)}
+ />
+ <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}>
+ 해당
+ </span>
+ </div>
+ </div> */}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ "저장"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
new file mode 100644
index 00000000..f31caf5e
--- /dev/null
+++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import { Loader2 } from 'lucide-react'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface VendorPriceAdjustmentViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorName: string
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부
+ biddingCompanyId: number
+}
+
+export function VendorPriceAdjustmentViewDialog({
+ open,
+ onOpenChange,
+ vendorName,
+ priceAdjustmentResponse,
+ biddingCompanyId,
+}: VendorPriceAdjustmentViewDialogProps) {
+ const [data, setData] = React.useState<PriceAdjustmentData | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadPriceAdjustmentData()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadPriceAdjustmentData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ // 서버에서 연동제 폼 데이터 조회
+ const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service')
+ const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId)
+ setData(formData)
+ } catch (err) {
+ console.error('Failed to load price adjustment data:', err)
+ setError('연동제 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null | undefined) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단
+ const isApplied = priceAdjustmentResponse === true
+ const isNotApplied = priceAdjustmentResponse === false
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ {priceAdjustmentResponse === null && (
+ <Badge variant="outline">해당없음</Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span>
+ </div>
+ ) : error ? (
+ <div className="py-8 text-center text-red-600">{error}</div>
+ ) : !data && priceAdjustmentResponse !== null ? (
+ <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div>
+ ) : priceAdjustmentResponse === null ? (
+ <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div>
+ ) : (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
+ <p className="text-sm font-medium">{data?.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && data && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">반영비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && data && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ {data && (
+ <>
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index 11955a39..b422118d 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -10,6 +10,96 @@
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
/**
+ * 결재 완료 시점을 기준으로 입찰서 제출기간 계산 및 업데이트
+ *
+ * 계산 로직:
+ * - baseDate = 결재완료일 날짜만 (00:00:00)
+ * - 시작일 = baseDate + submissionStartOffset일 + submissionStartDate의 시:분
+ * - 마감일 = 시작일(날짜만) + submissionDurationDays일 + submissionEndDate의 시:분
+ */
+async function calculateAndUpdateSubmissionDates(biddingId: number) {
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ // 현재 입찰 정보 조회
+ const biddingInfo = await db
+ .select({
+ submissionStartOffset: biddings.submissionStartOffset,
+ submissionDurationDays: biddings.submissionDurationDays,
+ submissionStartDate: biddings.submissionStartDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ submissionEndDate: biddings.submissionEndDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[calculateAndUpdateSubmissionDates] 입찰 정보를 찾을 수 없음', { biddingId });
+ throw new Error('입찰 정보를 찾을 수 없습니다.');
+ }
+
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = biddingInfo[0];
+
+ // 필수 값 검증
+ if (submissionStartOffset === null || submissionDurationDays === null) {
+ debugError('[calculateAndUpdateSubmissionDates] 오프셋 값이 설정되지 않음', { submissionStartOffset, submissionDurationDays });
+ throw new Error('입찰서 제출기간 오프셋이 설정되지 않았습니다.');
+ }
+
+ // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 };
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 };
+
+ // 1. baseDate = 결재완료일 날짜만 (KST 기준 00:00:00)
+ const now = new Date();
+ const baseDate = new Date(now);
+ // KST 기준으로 날짜만 추출 (시간은 00:00:00)
+ baseDate.setHours(0, 0, 0, 0);
+
+ // 2. 시작일 = baseDate + offset일 + 시작시간
+ const calculatedStartDate = new Date(baseDate);
+ calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset);
+ calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0);
+
+ // 3. 마감일 = 시작일(날짜만) + duration일 + 마감시간
+ const calculatedEndDate = new Date(calculatedStartDate);
+ calculatedEndDate.setHours(0, 0, 0, 0); // 시작일의 날짜만
+ calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays);
+ calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0);
+
+ debugLog('[calculateAndUpdateSubmissionDates] 입찰서 제출기간 계산 완료', {
+ biddingId,
+ baseDate: baseDate.toISOString(),
+ submissionStartOffset,
+ submissionDurationDays,
+ startTime,
+ endTime,
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ });
+
+ // DB 업데이트
+ await db
+ .update(biddings)
+ .set({
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddings.id, biddingId));
+
+ return {
+ startDate: calculatedStartDate,
+ endDate: calculatedEndDate,
+ };
+}
+
+/**
* 입찰초대 핸들러 (결재 승인 후 실행됨)
*
* ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지)
@@ -52,7 +142,7 @@ export async function requestBiddingInvitationInternal(payload: {
try {
// 1. 기본계약 발송
const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service');
-
+
const vendorDataForContract = payload.vendors.map(vendor => ({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
@@ -86,7 +176,7 @@ export async function requestBiddingInvitationInternal(payload: {
debugLog('[BiddingInvitationHandler] 기본계약 발송 완료');
- // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경)
+ // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경, 입찰서 제출기간 자동 계산)
const { registerBidding } = await import('@/lib/bidding/detail/service');
const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString());
@@ -127,6 +217,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
biddingNumber: string;
projectName?: string;
itemName?: string;
+ awardCount: string;
biddingType: string;
bidPicName?: string;
supplyPicName?: string;
@@ -181,7 +272,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload;
// 제목
- const title = bidding.title || '입찰';
+ const title = bidding.title || '';
// 입찰명
const biddingTitle = bidding.title || '';
@@ -190,7 +281,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const biddingNumber = bidding.biddingNumber || '';
// 낙찰업체수
- const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함
+ const awardCount = bidding.awardCount || '';
// 계약구분
const contractType = bidding.biddingType || '';
@@ -199,7 +290,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const prNumber = '';
// 예산
- const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const budget = bidding.budget ? bidding.budget.toLocaleString() : '';
// 내정가
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
@@ -219,9 +310,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
// 입찰 공고문
const biddingNotice = message || '';
- // 입찰담당자 (중복이지만 템플릿에 맞춤)
- const biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || '';
-
// 협력사 정보들
const vendorVariables: Record<string, string> = {};
vendors.forEach((vendor, index) => {
@@ -237,8 +325,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오';
const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : '';
const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : '';
- const specMeetingStartDup = specMeetingStart;
- const specMeetingEndDup = specMeetingEnd;
// 입찰서제출기간 정보
const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예
@@ -272,7 +358,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
제목: title,
입찰명: biddingTitle,
입찰번호: biddingNumber,
- 낙찰업체수: winnerCount,
+ 낙찰업체수: awardCount,
계약구분: contractType,
'P/R번호': prNumber,
예산: budget,
@@ -426,12 +512,13 @@ export async function requestBiddingClosureInternal(payload: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(payload.currentUserId.toString());
await db
.update(biddings)
.set({
status: 'bid_closure',
- updatedBy: payload.currentUserId.toString(),
+ updatedBy: userName,
updatedAt: new Date(),
remarks: payload.description, // 폐찰 사유를 remarks에 저장
})
@@ -618,6 +705,15 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
biddingId: number;
selectionReason: string;
requestedAt: Date;
+ awardedCompanies?: Array<{
+ companyId: number;
+ companyName: string | null;
+ finalQuoteAmount: number;
+ awardRatio: number;
+ vendorCode?: string | null;
+ companySize?: string | null;
+ targetPrice?: number | null;
+ }>;
}): Promise<Record<string, string>> {
const { biddingId, selectionReason, requestedAt } = payload;
@@ -637,6 +733,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
biddingType: biddings.biddingType,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
+ budget: biddings.budget,
targetPrice: biddings.targetPrice,
awardCount: biddings.awardCount,
})
@@ -652,8 +749,11 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const bidding = biddingInfo[0];
// 2. 낙찰된 업체 정보 조회
- const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
- const awardedCompanies = await getAwardedCompanies(biddingId);
+ let awardedCompanies = payload.awardedCompanies;
+ if (!awardedCompanies) {
+ const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
+ awardedCompanies = await getAwardedCompanies(biddingId);
+ }
// 3. 입찰 대상 자재 정보 조회
const biddingItemsInfo = await db
@@ -684,7 +784,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const biddingNumber = bidding.biddingNumber || '';
const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString();
const contractType = bidding.biddingType || '';
- const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const budget = bidding.budget ? bidding.budget.toLocaleString() : '';
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
const biddingOverview = bidding.itemName || '';
diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx
index 0be2172b..227a917b 100644
--- a/lib/bidding/list/biddings-page-header.tsx
+++ b/lib/bidding/list/biddings-page-header.tsx
@@ -4,7 +4,11 @@ import { Button } from "@/components/ui/button"
import { Plus, FileText, TrendingUp } from "lucide-react"
import { useRouter } from "next/navigation"
import { InformationButton } from "@/components/information/information-button"
-export function BiddingsPageHeader() {
+import { useTranslation } from "@/i18n/client"
+
+export function BiddingsPageHeader(props: {lng: string}) {
+ const {lng} = props
+ const {t} = useTranslation(lng, 'menu')
const router = useRouter()
return (
@@ -12,11 +16,11 @@ export function BiddingsPageHeader() {
{/* 좌측: 제목과 설명 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
- <h2 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h2>
+ <h2 className="text-3xl font-bold tracking-tight">{t('menu.procurement.bid_management')}</h2>
<InformationButton pagePath="evcp/bid" />
</div>
<p className="text-muted-foreground">
- 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다.
+ {t('menu.procurement.bid_management_desc')}
</p>
</div>
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 62d4dbe7..602bcbb9 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -257,21 +257,40 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
id: "submissionPeriod",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
cell: ({ row }) => {
+ const status = row.original.status
+
+ // 입찰생성 또는 결재진행중 상태일 때는 특별 메시지 표시
+ if (status === 'bidding_generated') {
+ return (
+ <div className="text-xs text-orange-600 font-medium">
+ 입찰 등록중입니다
+ </div>
+ )
+ }
+
+ if (status === 'approval_pending') {
+ return (
+ <div className="text-xs text-blue-600 font-medium">
+ 결재 진행중입니다
+ </div>
+ )
+ }
+
const startDate = row.original.submissionStartDate
const endDate = row.original.submissionEndDate
-
+
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
+
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</div>
)
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 33368218..b0007c8c 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -7,7 +7,7 @@ import {
} from "lucide-react"
import { toast } from "sonner"
import { useSession } from "next-auth/react"
-import { exportTableToExcel } from "@/lib/export"
+import { exportBiddingsToExcel } from "./export-biddings-to-excel"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
@@ -92,6 +92,23 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated'
}, [selectedBiddings])
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ try {
+ setIsExporting(true)
+ await exportBiddingsToExcel(table, {
+ filename: "입찰목록",
+ onlySelected: false,
+ })
+ toast.success("Excel 파일이 다운로드되었습니다.")
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel 내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }, [table])
+
return (
<>
<div className="flex items-center gap-2">
@@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
// 성공 시 테이블 새로고침 등 추가 작업
// window.location.reload()
}} />
+ {/* Excel 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isExporting}
+ className="gap-2"
+ >
+ <FileSpreadsheet className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span>
+ </Button>
{/* 전송하기 (업체선정 완료된 입찰만) */}
<Button
variant="default"
@@ -112,20 +140,16 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
<span className="hidden sm:inline">전송하기</span>
</Button>
{/* 삭제 버튼 */}
-
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsDeleteDialogOpen(true)}
- disabled={!canDelete}
- className="gap-2"
- >
- <Trash className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">삭제</span>
- </Button>
-
-
-
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => setIsDeleteDialogOpen(true)}
+ disabled={!canDelete}
+ className="gap-2"
+ >
+ <Trash className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">삭제</span>
+ </Button>
</div>
{/* 전송 다이얼로그 */}
diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts
new file mode 100644
index 00000000..64d98399
--- /dev/null
+++ b/lib/bidding/list/export-biddings-to-excel.ts
@@ -0,0 +1,209 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { BiddingListItem } from "@/db/schema"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+// BiddingListItem 확장 타입 (manager 정보 포함)
+type BiddingListItemWithManagerCode = BiddingListItem & {
+ bidPicName?: string | null
+ supplyPicName?: string | null
+}
+
+/**
+ * 입찰 목록을 Excel로 내보내기
+ * - 계약구분, 진행상태, 입찰유형은 라벨(명칭)로 변환
+ * - 입찰서 제출기간은 submissionStartDate, submissionEndDate 기준
+ * - 등록일시는 년, 월, 일 형식
+ */
+export async function exportBiddingsToExcel(
+ table: Table<BiddingListItemWithManagerCode>,
+ {
+ filename = "입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // select, actions 컬럼 제외
+ const columns = allColumns.filter(
+ (col) => !["select", "actions"].includes(col.id)
+ )
+
+ // 헤더 행 생성 (excelHeader 사용)
+ const headerRow = columns.map((col) => {
+ const excelHeader = (col.columnDef.meta as any)?.excelHeader
+ return typeof excelHeader === "string" ? excelHeader : col.id
+ })
+
+ // 데이터 행 생성
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) => {
+ const original = row.original
+ return columns.map((col) => {
+ const colId = col.id
+ let value: any
+
+ // 특별 처리 필요한 컬럼들
+ switch (colId) {
+ case "contractType":
+ // 계약구분: 라벨로 변환
+ value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType
+ break
+
+ case "status":
+ // 진행상태: 라벨로 변환
+ value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status
+ break
+
+ case "biddingType":
+ // 입찰유형: 라벨로 변환
+ value = biddingTypeLabels[original.biddingType as keyof typeof biddingTypeLabels] || original.biddingType
+ break
+
+ case "submissionPeriod":
+ // 입찰서 제출기간: submissionStartDate, submissionEndDate 기준
+ const startDate = original.submissionStartDate
+ const endDate = original.submissionEndDate
+
+ if (!startDate || !endDate) {
+ value = "-"
+ } else {
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
+ value = `${formatValue(startObj)} ~ ${formatValue(endObj)}`
+ }
+ break
+
+ case "updatedAt":
+ // 등록일시: 년, 월, 일 형식만
+ if (original.updatedAt) {
+ value = formatDate(original.updatedAt, "KR")
+ } else {
+ value = "-"
+ }
+ break
+
+ case "biddingRegistrationDate":
+ // 입찰등록일: 년, 월, 일 형식만
+ if (original.biddingRegistrationDate) {
+ value = formatDate(original.biddingRegistrationDate, "KR")
+ } else {
+ value = "-"
+ }
+ break
+
+ case "projectName":
+ // 프로젝트: 코드와 이름 조합
+ const code = original.projectCode
+ const name = original.projectName
+ value = code && name ? `${code} (${name})` : (code || name || "-")
+ break
+
+ case "hasSpecificationMeeting":
+ // 사양설명회: Yes/No
+ value = original.hasSpecificationMeeting ? "Yes" : "No"
+ break
+
+ default:
+ // 기본값: row.getValue 사용
+ value = row.getValue(colId)
+
+ // null/undefined 처리
+ if (value == null) {
+ value = ""
+ }
+
+ // 객체인 경우 JSON 문자열로 변환
+ if (typeof value === "object") {
+ value = JSON.stringify(value)
+ }
+ break
+ }
+
+ return value
+ })
+ })
+
+ // 최종 sheetData
+ const sheetData = [headerRow, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, columns.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts
new file mode 100644
index 00000000..814648a7
--- /dev/null
+++ b/lib/bidding/manage/export-bidding-items-to-excel.ts
@@ -0,0 +1,161 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectCodesByIds } from "./project-utils"
+
+/**
+ * 입찰품목 목록을 Excel로 내보내기
+ */
+export async function exportBiddingItemsToExcel(
+ items: PRItemInfo[],
+ {
+ filename = "입찰품목목록",
+ }: {
+ filename?: string
+ } = {}
+): Promise<void> {
+ // 프로젝트 ID 목록 수집
+ const projectIds = items
+ .map((item) => item.projectId)
+ .filter((id): id is number => id != null && id > 0)
+
+ // 프로젝트 코드 맵 조회
+ const projectCodeMap = await getProjectCodesByIds(projectIds)
+
+ // 헤더 정의
+ const headers = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 데이터 행 생성
+ const dataRows = items.map((item) => {
+ // 프로젝트 코드 조회
+ const projectCode = item.projectId
+ ? projectCodeMap.get(item.projectId) || ""
+ : ""
+
+ return [
+ projectCode,
+ item.projectInfo || "",
+ item.materialGroupNumber || "",
+ item.materialGroupInfo || "",
+ item.materialNumber || "",
+ item.materialInfo || "",
+ item.quantity || "",
+ item.quantityUnit || "",
+ item.totalWeight || "",
+ item.weightUnit || "",
+ item.requestedDeliveryDate || "",
+ item.priceUnit || "",
+ item.purchaseUnit || "",
+ item.materialWeight || "",
+ item.targetUnitPrice || "",
+ item.targetAmount || "",
+ item.targetCurrency || "KRW",
+ item.budgetAmount || "",
+ item.budgetCurrency || "KRW",
+ item.actualAmount || "",
+ item.actualCurrency || "KRW",
+ item.wbsCode || "",
+ item.wbsName || "",
+ item.costCenterCode || "",
+ item.costCenterName || "",
+ item.glAccountCode || "",
+ item.glAccountName || "",
+ item.prNumber || "",
+ ]
+ })
+
+ // 최종 sheetData
+ const sheetData = [headers, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, headers.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts
new file mode 100644
index 00000000..fe5b17a9
--- /dev/null
+++ b/lib/bidding/manage/import-bidding-items-from-excel.ts
@@ -0,0 +1,273 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectIdByCodeAndName } from "./project-utils"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+export interface ImportBiddingItemsResult {
+ success: boolean
+ items: PRItemInfo[]
+ errors: string[]
+}
+
+/**
+ * Excel 파일에서 입찰품목 데이터 파싱
+ */
+export async function importBiddingItemsFromExcel(
+ file: File
+): Promise<ImportBiddingItemsResult> {
+ const errors: string[] = []
+ const items: PRItemInfo[] = []
+
+ try {
+ const workbook = new ExcelJS.Workbook()
+ // DRM 해제 후 ArrayBuffer 획득 (DRM 서버 미연결 시 원본 반환)
+ const arrayBuffer = await decryptWithServerAction(file)
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ return {
+ success: false,
+ items: [],
+ errors: ["Excel 파일에 시트가 없습니다."],
+ }
+ }
+
+ // 헤더 행 읽기 (첫 번째 행)
+ const headerRow = worksheet.getRow(1)
+ const headerValues = headerRow.values as ExcelJS.CellValue[]
+
+ // 헤더 매핑 생성
+ const headerMap: Record<string, number> = {}
+ const expectedHeaders = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 헤더 인덱스 매핑
+ for (let i = 1; i < headerValues.length; i++) {
+ const headerValue = String(headerValues[i] || "").trim()
+ if (headerValue && expectedHeaders.includes(headerValue)) {
+ headerMap[headerValue] = i
+ }
+ }
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["자재그룹코드", "자재그룹명"]
+ const missingHeaders = requiredHeaders.filter(
+ (h) => !headerMap[h]
+ )
+ if (missingHeaders.length > 0) {
+ errors.push(
+ `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}`
+ )
+ }
+
+ // 데이터 행 읽기 (2번째 행부터)
+ for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) {
+ const row = worksheet.getRow(rowIndex)
+ const rowValues = row.values as ExcelJS.CellValue[]
+
+ // 빈 행 건너뛰기
+ if (rowValues.every((val) => !val || String(val).trim() === "")) {
+ continue
+ }
+
+ // 셀 값 추출 헬퍼
+ const getCellValue = (headerName: string): string => {
+ const colIndex = headerMap[headerName]
+ if (!colIndex) return ""
+ const value = rowValues[colIndex]
+ if (value == null) return ""
+
+ // ExcelJS 객체 처리
+ if (typeof value === "object" && "text" in value) {
+ return String((value as any).text || "")
+ }
+
+ // 날짜 처리
+ if (value instanceof Date) {
+ return value.toISOString().split("T")[0]
+ }
+
+ return String(value).trim()
+ }
+
+ // 필수값 검증
+ const materialGroupNumber = getCellValue("자재그룹코드")
+ const materialGroupInfo = getCellValue("자재그룹명")
+
+ if (!materialGroupNumber || !materialGroupInfo) {
+ errors.push(
+ `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.`
+ )
+ continue
+ }
+
+ // 수량 또는 중량 검증
+ const quantity = getCellValue("수량")
+ const totalWeight = getCellValue("중량")
+ const quantityUnit = getCellValue("수량단위")
+ const weightUnit = getCellValue("중량단위")
+
+ if (!quantity && !totalWeight) {
+ errors.push(
+ `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.`
+ )
+ continue
+ }
+
+ if (quantity && !quantityUnit) {
+ errors.push(
+ `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ if (totalWeight && !weightUnit) {
+ errors.push(
+ `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ // 납품요청일 검증
+ const requestedDeliveryDate = getCellValue("납품요청일")
+ if (!requestedDeliveryDate) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일은 필수입니다.`
+ )
+ continue
+ }
+
+ // 날짜 형식 검증
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/
+ if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)`
+ )
+ continue
+ }
+
+ // 내정단가 검증 (필수)
+ const targetUnitPrice = getCellValue("내정단가")
+ if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) {
+ errors.push(
+ `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.`
+ )
+ continue
+ }
+
+ // 숫자 값 정리 (콤마 제거)
+ const cleanNumber = (value: string): string => {
+ return value.replace(/,/g, "").trim()
+ }
+
+ // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로)
+ const projectCode = getCellValue("프로젝트코드")
+ const projectName = getCellValue("프로젝트명")
+ let projectId: number | null = null
+
+ if (projectCode && projectName) {
+ projectId = await getProjectIdByCodeAndName(projectCode, projectName)
+ if (!projectId) {
+ errors.push(
+ `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.`
+ )
+ // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시)
+ }
+ }
+
+ // PRItemInfo 객체 생성
+ const item: PRItemInfo = {
+ id: -(rowIndex - 1), // 임시 ID (음수)
+ prNumber: getCellValue("PR번호") || null,
+ projectId: projectId,
+ projectInfo: projectName || null,
+ shi: null,
+ quantity: quantity ? cleanNumber(quantity) : null,
+ quantityUnit: quantityUnit || null,
+ totalWeight: totalWeight ? cleanNumber(totalWeight) : null,
+ weightUnit: weightUnit || null,
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: requestedDeliveryDate || null,
+ isRepresentative: false,
+ annualUnitPrice: null,
+ currency: "KRW",
+ materialGroupNumber: materialGroupNumber || null,
+ materialGroupInfo: materialGroupInfo || null,
+ materialNumber: getCellValue("자재코드") || null,
+ materialInfo: getCellValue("자재명") || null,
+ priceUnit: getCellValue("가격단위") || "1",
+ purchaseUnit: getCellValue("구매단위") || "EA",
+ materialWeight: getCellValue("자재순중량") || null,
+ wbsCode: getCellValue("WBS코드") || null,
+ wbsName: getCellValue("WBS명") || null,
+ costCenterCode: getCellValue("코스트센터코드") || null,
+ costCenterName: getCellValue("코스트센터명") || null,
+ glAccountCode: getCellValue("GL계정코드") || null,
+ glAccountName: getCellValue("GL계정명") || null,
+ targetUnitPrice: cleanNumber(targetUnitPrice) || null,
+ targetAmount: getCellValue("내정금액")
+ ? cleanNumber(getCellValue("내정금액"))
+ : null,
+ targetCurrency: getCellValue("내정통화") || "KRW",
+ budgetAmount: getCellValue("예산금액")
+ ? cleanNumber(getCellValue("예산금액"))
+ : null,
+ budgetCurrency: getCellValue("예산통화") || "KRW",
+ actualAmount: getCellValue("실적금액")
+ ? cleanNumber(getCellValue("실적금액"))
+ : null,
+ actualCurrency: getCellValue("실적통화") || "KRW",
+ }
+
+ items.push(item)
+ }
+
+ return {
+ success: errors.length === 0,
+ items,
+ errors,
+ }
+ } catch (error) {
+ console.error("Excel import error:", error)
+ return {
+ success: false,
+ items: [],
+ errors: [
+ error instanceof Error
+ ? error.message
+ : "Excel 파일 파싱 중 오류가 발생했습니다.",
+ ],
+ }
+ }
+}
+
diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts
new file mode 100644
index 00000000..92744695
--- /dev/null
+++ b/lib/bidding/manage/project-utils.ts
@@ -0,0 +1,87 @@
+'use server'
+
+import db from '@/db/db'
+import { projects } from '@/db/schema'
+import { eq, and, inArray } from 'drizzle-orm'
+
+/**
+ * 프로젝트 ID로 프로젝트 코드 조회
+ */
+export async function getProjectCodeById(projectId: number): Promise<string | null> {
+ try {
+ const result = await db
+ .select({ code: projects.code })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1)
+
+ return result[0]?.code || null
+ } catch (error) {
+ console.error('Failed to get project code by id:', error)
+ return null
+ }
+}
+
+/**
+ * 프로젝트 코드와 이름으로 프로젝트 ID 조회
+ */
+export async function getProjectIdByCodeAndName(
+ projectCode: string,
+ projectName: string
+): Promise<number | null> {
+ try {
+ if (!projectCode || !projectName) {
+ return null
+ }
+
+ const result = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(
+ and(
+ eq(projects.code, projectCode.trim()),
+ eq(projects.name, projectName.trim())
+ )
+ )
+ .limit(1)
+
+ return result[0]?.id || null
+ } catch (error) {
+ console.error('Failed to get project id by code and name:', error)
+ return null
+ }
+}
+
+/**
+ * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화)
+ */
+export async function getProjectCodesByIds(
+ projectIds: number[]
+): Promise<Map<number, string>> {
+ try {
+ if (projectIds.length === 0) {
+ return new Map()
+ }
+
+ const uniqueIds = [...new Set(projectIds.filter(id => id != null))]
+ if (uniqueIds.length === 0) {
+ return new Map()
+ }
+
+ const result = await db
+ .select({ id: projects.id, code: projects.code })
+ .from(projects)
+ .where(inArray(projects.id, uniqueIds))
+
+ const map = new Map<number, string>()
+ result.forEach((project) => {
+ map.set(project.id, project.code)
+ })
+
+ return map
+ } catch (error) {
+ console.error('Failed to get project codes by ids:', error)
+ return new Map()
+ }
+}
+
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 08cb0e2c..6fef228c 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -49,16 +49,6 @@ interface UpdateBiddingCompanyInput {
isAttendingMeeting?: boolean
}
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
try {
@@ -201,16 +191,6 @@ export async function deleteBiddingCompany(id: number) {
}
-// 선택된 업체들에게 사전견적 초대 발송
-interface CompanyWithContacts {
- id: number
- companyId: number
- companyName: string
- selectedMainEmail: string
- additionalEmails: string[]
-}
-
-
// PR 아이템 조회 (입찰에 포함된 품목들)
export async function getPrItemsForBidding(biddingId: number, companyId?: number) {
try {
@@ -253,12 +233,11 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number
selectFields.bidAmount = companyPrItemBids.bidAmount
selectFields.proposedDeliveryDate = companyPrItemBids.proposedDeliveryDate
selectFields.technicalSpecification = companyPrItemBids.technicalSpecification
- }
-
- let query = db.select(selectFields).from(prItemsForBidding)
+ selectFields.currency = companyPrItemBids.currency
- if (companyId) {
- query = query
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
.leftJoin(biddingCompanies, and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.companyId, companyId)
@@ -266,13 +245,16 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number
.leftJoin(companyPrItemBids, and(
eq(companyPrItemBids.prItemId, prItemsForBidding.id),
eq(companyPrItemBids.biddingCompanyId, biddingCompanies.id)
- )) as any
+ ))
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+ } else {
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
}
-
- query = query.where(eq(prItemsForBidding.biddingId, biddingId)).orderBy(prItemsForBidding.id) as any
-
- const prItems = await query
- return prItems
} catch (error) {
console.error('Failed to get PR items for bidding:', error)
return []
@@ -877,8 +859,8 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
interface CreatePreQuoteRfqInput {
rfqType: string;
rfqTitle: string;
- dueDate: Date;
- picUserId: number;
+ dueDate?: Date;
+ picUserId: number | string | undefined;
projectId?: number;
remark?: string;
biddingNumber?: string;
@@ -893,6 +875,8 @@ interface CreatePreQuoteRfqInput {
remark?: string;
materialCode?: string;
materialName?: string;
+ totalWeight?: number | string | null; // 중량 추가
+ weightUnit?: string | null; // 중량단위 추가
}>;
biddingConditions?: {
paymentTerms?: string | null
@@ -994,6 +978,10 @@ export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) {
quantity: item.quantity, // 수량
uom: item.uom, // 단위
+ // 중량 정보
+ grossWeight: item.totalWeight ? (typeof item.totalWeight === 'string' ? parseFloat(item.totalWeight) : item.totalWeight) : null,
+ gwUom: item.weightUnit || null,
+
majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
remark: item.remark || null, // 비고
}));
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 9650574a..6847d9d5 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -1,404 +1,404 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
-} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-import { formatDate } from "@/lib/utils"
-import { DataTableRowAction } from "@/types/table"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
- onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
-}
-
-// 상태별 배지 색상
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'received_quotation':
- return 'secondary'
- case 'bidding_opened':
- return 'default'
- case 'bidding_closed':
- return 'outline'
- default:
- return 'outline'
- }
-}
-
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
-
- return [
- // ░░░ 선택 ░░░
- {
- id: "select",
- header: "",
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // single select 모드에서는 다른 행들의 선택을 해제
- row.toggleSelected(!!value)
- }}
- aria-label="행 선택"
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
-
- // ░░░ 입찰번호 ░░░
- {
- accessorKey: "biddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.biddingNumber}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "입찰번호" },
- },
-
- // ░░░ 입찰명 ░░░
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button> */}
- {row.original.title}
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
-
- // ░░░ 원입찰번호 ░░░
- {
- accessorKey: "originalBiddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.originalBiddingNumber || '-'}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "원입찰번호" },
- },
-
- // ░░░ 진행상태 ░░░
- {
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
- cell: ({ row }) => (
- <Badge variant={getStatusBadgeVariant(row.original.status)}>
- {biddingStatusLabels[row.original.status]}
- </Badge>
- ),
- size: 120,
- meta: { excelHeader: "진행상태" },
- },
-
- // ░░░ 계약구분 ░░░
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- // ░░░ 입찰서제출기간 ░░░
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const startObj = new Date(startDate)
- const endObj = new Date(endDate)
-
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
- return (
- <div className="text-xs">
- <div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
- </div>
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- // ░░░ P/R번호 ░░░
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "P/R번호" },
- },
-
- // ░░░ 입찰담당자 ░░░
- {
- accessorKey: "bidPicName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
- cell: ({ row }) => {
- const bidPic = row.original.bidPicName
- const supplyPic = row.original.supplyPicName
-
- const displayName = bidPic || supplyPic || "-"
- return <span className="text-sm">{displayName}</span>
- },
- size: 100,
- meta: { excelHeader: "입찰담당자" },
- },
-
- // ░░░ 참여예정협력사 ░░░
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-blue-50"
- onClick={() => onParticipantClick?.(row.original.id, 'expected')}
- disabled={row.original.participantExpected === 0}
- >
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여예정협력사" },
- },
-
- // ░░░ 참여협력사 ░░░
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-green-50"
- onClick={() => onParticipantClick?.(row.original.id, 'participated')}
- disabled={row.original.participantParticipated === 0}
- >
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여협력사" },
- },
-
- // ░░░ 포기협력사 ░░░
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-red-50"
- onClick={() => onParticipantClick?.(row.original.id, 'declined')}
- disabled={row.original.participantDeclined === 0}
- >
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "포기협력사" },
- },
-
- // ░░░ 미제출협력사 ░░░
- {
- id: "participantPending",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-yellow-50"
- onClick={() => onParticipantClick?.(row.original.id, 'pending')}
- disabled={row.original.participantPending === 0}
- >
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "미제출협력사" },
- },
-
- // ░░░ 개찰자명 ░░░
- {
- id: "openedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
- cell: ({ row }) => {
- const openedBy = row.original.openedBy
- return <span className="text-sm">{openedBy || '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰자명" },
- },
-
- // ░░░ 개찰일 ░░░
- {
- id: "openedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
- cell: ({ row }) => {
- const openedAt = row.original.openedAt
- return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰일" },
- },
-
- // ░░░ 등록자 ░░░
- {
- accessorKey: "createdBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록자" },
- },
-
- // ░░░ 등록일시 ░░░
- {
- accessorKey: "createdAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록일시" },
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- // {
- // id: "actions",
- // header: "액션",
- // cell: ({ row }) => (
- // <DropdownMenu>
- // <DropdownMenuTrigger asChild>
- // <Button variant="ghost" className="h-8 w-8 p-0">
- // <span className="sr-only">메뉴 열기</span>
- // <AlertTriangle className="h-4 w-4" />
- // </Button>
- // </DropdownMenuTrigger>
- // <DropdownMenuContent align="end">
- // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- // <Eye className="mr-2 h-4 w-4" />
- // 상세보기
- // </DropdownMenuItem>
- // </DropdownMenuContent>
- // </DropdownMenu>
- // ),
- // size: 50,
- // enableSorting: false,
- // enableHiding: false,
- // },
- ]
-}
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+ onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'received_quotation':
+ return 'secondary'
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+
+ return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ {/* <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button> */}
+ {row.original.title}
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰서제출기간 ░░░
+ {
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
+ return (
+ <div className="text-xs">
+ <div>
+ {formatValue(startObj)} ~ {formatValue(endObj)}
+ </div>
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 참여예정협력사 ░░░
+ {
+ id: "participantExpected",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-blue-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'expected')}
+ disabled={row.original.participantExpected === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여예정협력사" },
+ },
+
+ // ░░░ 참여협력사 ░░░
+ {
+ id: "participantParticipated",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-green-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'participated')}
+ disabled={row.original.participantParticipated === 0}
+ >
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여협력사" },
+ },
+
+ // ░░░ 포기협력사 ░░░
+ {
+ id: "participantDeclined",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-red-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'declined')}
+ disabled={row.original.participantDeclined === 0}
+ >
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "포기협력사" },
+ },
+
+ // ░░░ 미제출협력사 ░░░
+ {
+ id: "participantPending",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-yellow-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'pending')}
+ disabled={row.original.participantPending === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "미제출협력사" },
+ },
+
+ // ░░░ 개찰자명 ░░░
+ {
+ id: "openedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
+ cell: ({ row }) => {
+ const openedBy = row.original.openedBy
+ return <span className="text-sm">{openedBy || '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰자명" },
+ },
+
+ // ░░░ 개찰일 ░░░
+ {
+ id: "openedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
+ cell: ({ row }) => {
+ const openedAt = row.original.openedAt
+ return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰일" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ // {
+ // id: "actions",
+ // header: "액션",
+ // cell: ({ row }) => (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">메뉴 열기</span>
+ // <AlertTriangle className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ // <Eye className="mr-2 h-4 w-4" />
+ // 상세보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
+ ]
+}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 2b141d5e..6a48fa79 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -1,296 +1,297 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { Button } from "@/components/ui/button"
-import { Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getBiddingsReceiveColumns } from "./biddings-receive-columns"
-import { getBiddingsForReceive } from "@/lib/bidding/service"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction } from "@/lib/bidding/actions"
-import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
-import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
- participantFinalSubmitted: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface BiddingsReceiveTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getBiddingsForReceive>>
- ]
- >
-}
-
-export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
- const [biddingsResult] = React.use(promises)
-
- // biddingsResult에서 data와 pageCount 추출
- const { data, pageCount } = biddingsResult
-
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
- // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
- const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
-
- // 협력사 다이얼로그 관련 상태
- const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
- const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
- const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
- const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
- const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
-
- const router = useRouter()
- const { data: session } = useSession()
-
- // 협력사 클릭 핸들러
- const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
- setSelectedBiddingId(biddingId)
- setSelectedParticipantType(participantType)
- setIsLoadingParticipants(true)
- setParticipantsDialogOpen(true)
-
- try {
- // 협력사 데이터 로드 (모든 초대된 협력사)
- const companies = await getAllBiddingCompanies(biddingId)
-
- console.log('Loaded companies:', companies)
-
- // 필터링 없이 모든 데이터 그대로 표시
- // invitationStatus가 그대로 다이얼로그에 표시됨
- setParticipantCompanies(companies)
- } catch (error) {
- console.error('Failed to load participant companies:', error)
- toast.error('협력사 목록을 불러오는데 실패했습니다.')
- setParticipantCompanies([])
- } finally {
- setIsLoadingParticipants(false)
- }
- }, [])
-
- const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
- [setRowAction, handleParticipantClick]
- )
-
- // rowAction 변경 감지하여 해당 다이얼로그 열기
- React.useEffect(() => {
- if (rowAction) {
- setSelectedBidding(rowAction.row.original)
-
- switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- default:
- break
- }
- }
- }, [rowAction, router])
-
- const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
- {
- id: "biddingNumber",
- label: "입찰번호",
- placeholder: "입찰번호를 입력하세요",
- },
- {
- id: "prNumber",
- label: "P/R번호",
- placeholder: "P/R번호를 입력하세요",
- },
- {
- id: "title",
- label: "입찰명",
- placeholder: "입찰명을 입력하세요",
- },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
- { id: "title", label: "입찰명", type: "text" },
- { id: "biddingNumber", label: "입찰번호", type: "text" },
- { id: "bidPicName", label: "입찰담당자", type: "text" },
- {
- id: "status",
- label: "진행상태",
- type: "multi-select",
- options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- {
- id: "contractType",
- label: "계약구분",
- type: "select",
- options: Object.entries(contractTypeLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "submissionStartDate", label: "제출시작일", type: "date" },
- { id: "submissionEndDate", label: "제출마감일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableRowSelection: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
-
- // 선택된 행 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
-
- // 개찰 가능 여부 확인
- const canOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
-
- // 1. 입찰 마감일이 지났으면 무조건 가능
- if (submissionEndDate && now > submissionEndDate) return true
-
- // 2. 입찰 기간 내 조기개찰 조건 확인
- // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
- const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
- const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
-
- return isEarlyOpenPossible
- }, [selectedBiddingForAction])
-
- const handleOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
-
- setIsOpeningBidding(true)
- try {
- const result = await openBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("개찰 중 오류가 발생했습니다.")
- } finally {
- setIsOpeningBidding(false)
- }
- }, [selectedBiddingForAction])
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="biddingsReceiveTableCompact"
- onCompactChange={handleCompactChange}
- >
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
- >
- {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 개찰
- </Button>
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 사양설명회 다이얼로그 */}
- {/* <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* PR 문서 다이얼로그 */}
- {/* <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* 참여 협력사 다이얼로그 */}
- <BiddingParticipantsDialog
- open={participantsDialogOpen}
- onOpenChange={setParticipantsDialogOpen}
- biddingId={selectedBiddingId}
- participantType={selectedParticipantType}
- companies={participantCompanies}
- />
- </>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { Button } from "@/components/ui/button"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getBiddingsReceiveColumns } from "./biddings-receive-columns"
+import { getBiddingsForReceive } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { openBiddingAction } from "@/lib/bidding/actions"
+import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
+import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+ participantFinalSubmitted: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface BiddingsReceiveTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForReceive>>
+ ]
+ >
+}
+
+export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
+ const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+
+ // 협력사 다이얼로그 관련 상태
+ const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
+ const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
+ const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
+ const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
+ const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ // 협력사 클릭 핸들러
+ const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
+ setSelectedBiddingId(biddingId)
+ setSelectedParticipantType(participantType)
+ setIsLoadingParticipants(true)
+ setParticipantsDialogOpen(true)
+
+ try {
+ // 협력사 데이터 로드 (모든 초대된 협력사)
+ const companies = await getAllBiddingCompanies(biddingId)
+
+ console.log('Loaded companies:', companies)
+
+ // 필터링 없이 모든 데이터 그대로 표시
+ // invitationStatus가 그대로 다이얼로그에 표시됨
+ setParticipantCompanies(companies)
+ } catch (error) {
+ console.error('Failed to load participant companies:', error)
+ toast.error('협력사 목록을 불러오는데 실패했습니다.')
+ setParticipantCompanies([])
+ } finally {
+ setIsLoadingParticipants(false)
+ }
+ }, [])
+
+ const columns = React.useMemo(
+ () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
+ [setRowAction, handleParticipantClick]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", label: "제출마감일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableRowSelection: true,
+ enableMultiRowSelection: false, // 단일 선택만 가능
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
+ // 선택된 행 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
+
+ // 개찰 가능 여부 확인
+ const canOpen = React.useMemo(() => {
+ if (!selectedBiddingForAction) return false
+
+ const now = new Date()
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
+
+ // 1. 입찰 마감일이 지났으면 무조건 가능
+ if (submissionEndDate && now > submissionEndDate) return true
+
+ // 2. 입찰 기간 내 조기개찰 조건 확인
+ // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
+ const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
+ const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
+
+ return isEarlyOpenPossible
+ }, [selectedBiddingForAction])
+
+ const handleOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsOpeningBidding(true)
+ try {
+ const result = await openBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsReceiveTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenBidding}
+ disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
+ >
+ {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 개찰
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ {/* <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ /> */}
+
+ {/* PR 문서 다이얼로그 */}
+ {/* <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ /> */}
+
+ {/* 참여 협력사 다이얼로그 */}
+ <BiddingParticipantsDialog
+ open={participantsDialogOpen}
+ onOpenChange={setParticipantsDialogOpen}
+ biddingId={selectedBiddingId}
+ participantType={selectedParticipantType}
+ companies={participantCompanies}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts
index f19fbe6d..06dcbea1 100644
--- a/lib/bidding/selection/actions.ts
+++ b/lib/bidding/selection/actions.ts
@@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) {
}
}
+// 선정결과 조회
+export async function getSelectionResult(biddingId: number) {
+ try {
+ // 선정결과 조회 (selectedCompanyId가 null인 레코드)
+ const allResults = await db
+ .select()
+ .from(vendorSelectionResults)
+ .where(eq(vendorSelectionResults.biddingId, biddingId))
+
+ // @ts-ignore
+ const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1)
+
+ if (existingResult.length === 0) {
+ return {
+ success: true,
+ data: {
+ summary: '',
+ attachments: []
+ }
+ }
+ }
+
+ const result = existingResult[0]
+
+ // 첨부파일 조회
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ mimeType: biddingDocuments.mimeType,
+ filePath: biddingDocuments.filePath,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'selection_result')
+ ))
+
+ return {
+ success: true,
+ data: {
+ summary: result.evaluationSummary || '',
+ attachments: documents.map(doc => ({
+ id: doc.id,
+ fileName: doc.fileName || doc.originalFileName || '',
+ originalFileName: doc.originalFileName || '',
+ fileSize: doc.fileSize || 0,
+ mimeType: doc.mimeType || '',
+ filePath: doc.filePath || '',
+ uploadedAt: doc.uploadedAt
+ }))
+ }
+ }
+ } catch (error) {
+ console.error('Failed to get selection result:', error)
+ return {
+ success: false,
+ error: '선정결과 조회 중 오류가 발생했습니다.',
+ data: {
+ summary: '',
+ attachments: []
+ }
+ }
+ }
+}
+
// 견적 히스토리 조회
export async function getQuotationHistory(biddingId: number, vendorId: number) {
try {
@@ -168,12 +237,14 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
.where(eq(biddings.originalBiddingNumber, baseNumber))
.orderBy(biddings.createdAt)
- // 각 bidding에 대한 벤더의 견적 정보 조회
+ // 각 bidding에 대한 벤더의 견적 정보 및 상세 아이템 조회
const historyPromises = relatedBiddings.map(async (bidding) => {
+ // 1. 견적 헤더 정보 조회 (ID 포함)
const biddingCompanyData = await db
.select({
+ id: biddingCompanies.id,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- responseSubmittedAt: biddingCompanies.responseSubmittedAt,
+ responseSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
isFinalSubmission: biddingCompanies.isFinalSubmission
})
.from(biddingCompanies)
@@ -187,84 +258,72 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
return null
}
- return {
- biddingId: bidding.id,
- biddingNumber: bidding.biddingNumber,
- finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount,
- responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt,
- isFinalSubmission: biddingCompanyData[0].isFinalSubmission,
- targetPrice: bidding.targetPrice,
- currency: bidding.currency
- }
- })
-
- const historyData = (await Promise.all(historyPromises)).filter(Boolean)
-
- // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등)
- const sortedHistory = historyData.sort((a, b) => {
- const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0
- const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0
- return aSuffix - bSuffix
- })
-
- // PR 항목 정보 조회 (현재 bidding 기준)
- const prItems = await db
- .select({
- id: prItemsForBidding.id,
- itemNumber: prItemsForBidding.itemNumber,
- itemInfo: prItemsForBidding.itemInfo,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
-
- // 각 히스토리 항목에 대한 PR 아이템 견적 조회
- const history = await Promise.all(sortedHistory.map(async (item, index) => {
- // 각 bidding에 대한 PR 아이템 견적 조회
+ // 2. 아이템별 견적 및 상세 정보 조회 (Join 사용)
const prItemBids = await db
.select({
- prItemId: companyPrItemBids.prItemId,
+ // 견적 정보
bidUnitPrice: companyPrItemBids.bidUnitPrice,
bidAmount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ // 아이템 상세 정보
+ prItemId: prItemsForBidding.id,
+ itemNumber: prItemsForBidding.itemNumber,
+ itemInfo: prItemsForBidding.itemInfo,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate
})
.from(companyPrItemBids)
- .where(and(
- eq(companyPrItemBids.biddingId, item!.biddingId),
- eq(companyPrItemBids.companyId, vendorId)
- ))
+ .innerJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
+ .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyData[0].id))
- const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null
- const totalAmount = parseFloat(item!.finalQuoteAmount.toString())
+ // 아이템 매핑
+ const items = prItemBids.map(bid => ({
+ itemCode: bid.itemNumber || `ITEM${bid.prItemId}`,
+ itemName: bid.itemInfo || '품목 정보 없음',
+ quantity: bid.quantity ? parseFloat(bid.quantity.toString()) : 0,
+ unit: bid.quantityUnit || 'EA',
+ unitPrice: bid.bidUnitPrice ? parseFloat(bid.bidUnitPrice.toString()) : 0,
+ totalPrice: bid.bidAmount ? parseFloat(bid.bidAmount.toString()) : 0,
+ deliveryDate: bid.proposedDeliveryDate
+ ? new Date(bid.proposedDeliveryDate)
+ : bid.requestedDeliveryDate
+ ? new Date(bid.requestedDeliveryDate)
+ : new Date()
+ }))
+
+ const targetPrice = bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null
+ const totalAmount = parseFloat(biddingCompanyData[0].finalQuoteAmount.toString())
const vsTargetPrice = targetPrice && targetPrice > 0
? ((totalAmount - targetPrice) / targetPrice) * 100
: 0
- const items = prItemBids.map(bid => {
- const prItem = prItems.find(p => p.id === bid.prItemId)
- return {
- itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`,
- itemName: prItem?.itemInfo || '품목 정보 없음',
- quantity: prItem?.quantity || 0,
- unit: prItem?.quantityUnit || 'EA',
- unitPrice: parseFloat(bid.bidUnitPrice.toString()),
- totalPrice: parseFloat(bid.bidAmount.toString()),
- deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date()
- }
- })
-
return {
- id: item!.biddingId,
- round: index + 1, // 1차, 2차, 3차...
- submittedAt: new Date(item!.responseSubmittedAt),
+ biddingId: bidding.id,
+ biddingNumber: bidding.biddingNumber,
+ submittedAt: new Date(biddingCompanyData[0].responseSubmittedAt),
totalAmount,
- currency: item!.currency || 'KRW',
+ currency: bidding.currency || 'KRW',
vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)),
items
}
+ })
+
+ const historyData = (await Promise.all(historyPromises)).filter(Boolean)
+
+ // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등)
+ const sortedHistory = historyData.sort((a, b) => {
+ const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0
+ const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0
+ return aSuffix - bSuffix
+ })
+
+ // 회차 정보 추가
+ const history = sortedHistory.map((item, index) => ({
+ id: item!.biddingId,
+ round: index + 1, // 1차, 2차, 3차...
+ ...item!
}))
return {
diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx
index 8864e7db..5904bf65 100644
--- a/lib/bidding/selection/bidding-info-card.tsx
+++ b/lib/bidding/selection/bidding-info-card.tsx
@@ -5,6 +5,18 @@ import { Badge } from '@/components/ui/badge'
// import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
+// 입찰유형 라벨 맵 추가
+const biddingTypeLabels: Record<string, string> = {
+ equipment: '기자재',
+ construction: '공사',
+ service: '용역',
+ lease: '임차',
+ transport: '운송',
+ waste: '폐기물',
+ sale: '매각',
+ other: '기타(직접입력)',
+}
+
interface BiddingInfoCardProps {
bidding: Bidding
}
@@ -56,7 +68,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) {
입찰유형
</label>
<div className="text-sm font-medium">
- {bidding.isPublic ? '공개입찰' : '비공개입찰'}
+ {biddingTypeLabels[bidding.biddingType as keyof typeof biddingTypeLabels] || bidding.biddingType || '-'}
</div>
</div>
diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx
new file mode 100644
index 00000000..aa2b34ec
--- /dev/null
+++ b/lib/bidding/selection/bidding-item-table.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/service'
+import { formatNumber } from '@/lib/utils'
+import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
+
+interface BiddingItemTableProps {
+ biddingId: number
+}
+
+export function BiddingItemTable({ biddingId }: BiddingItemTableProps) {
+ const [data, setData] = React.useState<{
+ prItems: any[]
+ vendorPrices: any[]
+ }>({ prItems: [], vendorPrices: [] })
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ let isMounted = true
+
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const { prItems, vendorPrices } = await getBiddingSelectionItemsAndPrices(biddingId)
+
+ if (isMounted) {
+ console.log('prItems', prItems)
+ console.log('vendorPrices', vendorPrices)
+ setData({ prItems, vendorPrices })
+ }
+ } catch (error) {
+ console.error('Failed to load bidding items:', error)
+ } finally {
+ if (isMounted) {
+ setLoading(false)
+ }
+ }
+ }
+
+ loadData()
+
+ return () => {
+ isMounted = false
+ }
+ }, [biddingId])
+
+ // Memoize calculations
+ const totals = React.useMemo(() => {
+ const { prItems } = data
+ return {
+ quantity: prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
+ weight: prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0),
+ targetAmount: prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0)
+ }
+ }, [data.prItems])
+
+ const vendorTotals = React.useMemo(() => {
+ const { vendorPrices } = data
+ return vendorPrices.map(vendor => {
+ const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0)
+ return {
+ companyId: vendor.companyId,
+ totalAmount: total
+ }
+ })
+ }, [data.vendorPrices])
+
+ if (loading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">로딩 중...</div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ const { prItems, vendorPrices } = data
+
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ScrollArea className="w-full whitespace-nowrap rounded-md border">
+ <div className="w-max min-w-full">
+ <table className="w-full caption-bottom text-sm">
+ <thead className="[&_tr]:border-b">
+ {/* Header Row 1: Base Info + Vendor Groups */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th>
+
+ {vendorPrices.map((vendor) => (
+ <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20">
+ {vendor.companyName}
+ </th>
+ ))}
+ </tr>
+ {/* Header Row 2: Vendor Sub-columns */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ {vendorPrices.map((vendor) => (
+ <React.Fragment key={vendor.companyId}>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th>
+ </React.Fragment>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="[&_tr:last-child]:border-0">
+ {/* Summary Row */}
+ <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold">
+ <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totals.quantity)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totals.weight)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totals.targetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">KRW</td>
+
+ {vendorPrices.map((vendor) => {
+ const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0
+ const ratio = totals.targetAmount > 0 ? (vTotal / totals.targetAmount) * 100 : 0
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td>
+ <td className="p-4 align-middle text-center border-r">{vendor.currency}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+
+ {/* Data Rows */}
+ {prItems.map((item) => (
+ <tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
+ <td className="p-4 align-middle border-r">{item.materialNumber}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td>
+ <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.currency}</td>
+
+ {vendorPrices.map((vendor) => {
+ const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id)
+ const bidAmount = bidItem ? bidItem.amount : 0
+ const targetAmt = Number(item.targetAmount || 0)
+ const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0
+
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.unitPrice) : '-'}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.amount) : '-'}
+ </td>
+ <td className="p-4 align-middle text-center border-r bg-muted/5">
+ {vendor.currency}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'}
+ </td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ <ScrollBar orientation="horizontal" />
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx
index 45d5d402..887498dc 100644
--- a/lib/bidding/selection/bidding-selection-detail-content.tsx
+++ b/lib/bidding/selection/bidding-selection-detail-content.tsx
@@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema'
import { BiddingInfoCard } from './bidding-info-card'
import { SelectionResultForm } from './selection-result-form'
import { VendorSelectionTable } from './vendor-selection-table'
+import { BiddingItemTable } from './bidding-item-table'
interface BiddingSelectionDetailContentProps {
biddingId: number
@@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({
}: BiddingSelectionDetailContentProps) {
const [refreshKey, setRefreshKey] = React.useState(0)
+ // 입찰평가중 상태가 아니면 읽기 전용
+ const isReadOnly = bidding.status !== 'evaluation_of_bidding'
+
const handleRefresh = React.useCallback(() => {
setRefreshKey(prev => prev + 1)
}, [])
@@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({
<BiddingInfoCard bidding={bidding} />
{/* 선정결과 폼 */}
- <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} />
+ <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} />
{/* 업체선정 테이블 */}
<VendorSelectionTable
@@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({
biddingId={biddingId}
bidding={bidding}
onRefresh={handleRefresh}
+ readOnly={isReadOnly}
/>
+
+ {/* 응찰품목 테이블 */}
+ <BiddingItemTable biddingId={biddingId} />
+
</div>
)
}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 87c489e3..030fc05b 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // 비교로직만 유지, 색상표기/마감뱃지 제거
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</div>
)
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index c3990e7b..41225531 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx
index 54687cc9..af6b8d43 100644
--- a/lib/bidding/selection/selection-result-form.tsx
+++ b/lib/bidding/selection/selection-result-form.tsx
@@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { useToast } from '@/hooks/use-toast'
-import { saveSelectionResult } from './actions'
-import { Loader2, Save, FileText } from 'lucide-react'
+import { saveSelectionResult, getSelectionResult } from './actions'
+import { Loader2, Save, FileText, Download, X } from 'lucide-react'
import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone'
const selectionResultSchema = z.object({
@@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer<typeof selectionResultSchema>
interface SelectionResultFormProps {
biddingId: number
onSuccess: () => void
+ readOnly?: boolean
}
-export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) {
+interface AttachmentInfo {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ mimeType: string
+ filePath: string
+ uploadedAt: Date | null
+}
+
+export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(true)
const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([])
+ const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([])
const form = useForm<SelectionResultFormData>({
resolver: zodResolver(selectionResultSchema),
@@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
},
})
+ // 기존 선정결과 로드
+ React.useEffect(() => {
+ const loadSelectionResult = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getSelectionResult(biddingId)
+ if (result.success && result.data) {
+ form.reset({
+ summary: result.data.summary || '',
+ })
+ if (result.data.attachments) {
+ setExistingAttachments(result.data.attachments)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load selection result:', error)
+ toast({
+ title: '로드 실패',
+ description: '선정결과를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSelectionResult()
+ }, [biddingId, form, toast])
+
const removeAttachmentFile = (index: number) => {
setAttachmentFiles(prev => prev.filter((_, i) => i !== index))
}
+ const removeExistingAttachment = (id: number) => {
+ setExistingAttachments(prev => prev.filter(att => att.id !== id))
+ }
+
+ const downloadAttachment = (filePath: string, fileName: string) => {
+ // 파일 다운로드 (filePath가 절대 경로인 경우)
+ if (filePath.startsWith('http') || filePath.startsWith('/')) {
+ window.open(filePath, '_blank')
+ } else {
+ // 상대 경로인 경우
+ window.open(`/api/files/${filePath}`, '_blank')
+ }
+ }
+
const onSubmit = async (data: SelectionResultFormData) => {
setIsSubmitting(true)
try {
@@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
}
}
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>선정결과</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
return (
<Card>
<CardHeader>
@@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
placeholder="선정결과에 대한 요약을 입력해주세요..."
className="min-h-[120px]"
{...field}
+ disabled={readOnly}
/>
</FormControl>
<FormMessage />
@@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
{/* 첨부파일 */}
<div className="space-y-4">
<FormLabel>첨부파일</FormLabel>
- <Dropzone
- maxSize={10 * 1024 * 1024} // 10MB
- onDropAccepted={(files) => {
- const newFiles = Array.from(files)
- setAttachmentFiles(prev => [...prev, ...newFiles])
- }}
- onDropRejected={() => {
- toast({
- title: "파일 업로드 거부",
- description: "파일 크기 및 형식을 확인해주세요.",
- variant: "destructive",
- })
- }}
- >
- <DropzoneZone>
- <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
- <DropzoneTitle className="text-lg font-medium">
- 파일을 드래그하거나 클릭하여 업로드
- </DropzoneTitle>
- <DropzoneDescription className="text-sm text-muted-foreground">
- PDF, Word, Excel, 이미지 파일 (최대 10MB)
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
+
+ {/* 기존 첨부파일 */}
+ {existingAttachments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 첨부파일</h4>
+ <div className="space-y-2">
+ {existingAttachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeExistingAttachment(attachment.id)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {!readOnly && (
+ <Dropzone
+ maxSize={10 * 1024 * 1024} // 10MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "파일 업로드 거부",
+ description: "파일 크기 및 형식을 확인해주세요.",
+ variant: "destructive",
+ })
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+ )}
{attachmentFiles.length > 0 && (
<div className="space-y-2">
- <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <h4 className="text-sm font-medium">새로 추가할 파일</h4>
<div className="space-y-2">
{attachmentFiles.map((file, index) => (
<div
@@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</p>
</div>
</div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachmentFile(index)}
- >
- 제거
- </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachmentFile(index)}
+ >
+ 제거
+ </Button>
+ )}
</div>
))}
</div>
@@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</div>
{/* 저장 버튼 */}
- <div className="flex justify-end">
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- <Save className="mr-2 h-4 w-4" />
- 저장
- </Button>
- </div>
+ {!readOnly && (
+ <div className="flex justify-end">
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ <Save className="mr-2 h-4 w-4" />
+ 저장
+ </Button>
+ </div>
+ )}
</form>
</Form>
</CardContent>
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx
index 8570b5b6..40f13ec1 100644
--- a/lib/bidding/selection/vendor-selection-table.tsx
+++ b/lib/bidding/selection/vendor-selection-table.tsx
@@ -10,9 +10,10 @@ interface VendorSelectionTableProps {
biddingId: number
bidding: Bidding
onRefresh: () => void
+ readOnly?: boolean
}
-export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) {
+export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) {
const [vendors, setVendors] = React.useState<any[]>([])
const [loading, setLoading] = React.useState(true)
@@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe
vendors={vendors}
onRefresh={onRefresh}
onOpenSelectionReasonDialog={() => {}}
+ readOnly={readOnly}
/>
</CardContent>
</Card>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index a658ee6a..ed20ad0c 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -18,6 +18,7 @@ import {
vendorContacts,
vendors
} from '@/db/schema'
+import { companyConditionResponses } from '@/db/schema/bidding'
import {
eq,
desc,
@@ -39,8 +40,11 @@ import {
import { revalidatePath } from 'next/cache'
import { filterColumns } from '@/lib/filter-columns'
import { GetBiddingsSchema, CreateBiddingSchema } from './validation'
-import { saveFile } from '../file-stroage'
-
+import { saveFile, saveBuffer } from '../file-stroage'
+import { decryptBufferWithServerAction } from '@/components/drm/drmUtils'
+import { getVendorPricesForBidding } from './detail/service'
+import { getPrItemsForBidding } from './pre-quote/service'
+import { checkChemicalSubstance, checkMultipleChemicalSubstances, type ChemicalSubstanceResult } from '@/lib/soap/ecc/send/chemical-substance-check'
// 사용자 이메일로 사용자 코드 조회
@@ -59,6 +63,27 @@ export async function getUserCodeByEmail(email: string): Promise<string | null>
}
}
+// 사용자 ID로 상세 정보 조회 (이름, 코드 등)
+export async function getUserDetails(userId: number) {
+ try {
+ const user = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ userCode: users.userCode,
+ employeeNumber: users.employeeNumber
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1)
+
+ return user[0] || null
+ } catch (error) {
+ console.error('Failed to get user details:', error)
+ return null
+ }
+}
+
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
try {
@@ -419,9 +444,10 @@ export async function getBiddings(input: GetBiddingsSchema) {
// 메타 정보
remarks: biddings.remarks,
updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
+ updatedBy: users.name,
})
.from(biddings)
+ .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`)
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -846,7 +872,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
.insert(biddings)
.values({
biddingNumber,
- originalBiddingNumber: null, // 원입찰번호는 초기 생성이므로 아직 없음
+ originalBiddingNumber: biddingNumber.split('-')[0],
revision: input.revision || 0,
// 프로젝트 정보 (PR 아이템에서 설정됨)
@@ -872,7 +898,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
biddingRegistrationDate: new Date(),
submissionStartDate: parseDate(input.submissionStartDate),
submissionEndDate: parseDate(input.submissionEndDate),
- evaluationDate: parseDate(input.evaluationDate),
hasSpecificationMeeting: input.hasSpecificationMeeting || false,
hasPrDocument: input.hasPrDocument || false,
@@ -911,6 +936,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
await tx.insert(biddingNoticeTemplate).values({
biddingId,
title: input.title + ' 입찰공고',
+ type: input.noticeType || 'standard',
content: input.content || standardContent,
isTemplate: false,
})
@@ -1721,7 +1747,6 @@ export async function updateBiddingBasicInfo(
contractEndDate?: string
submissionStartDate?: string
submissionEndDate?: string
- evaluationDate?: string
hasSpecificationMeeting?: boolean
hasPrDocument?: boolean
currency?: string
@@ -1779,9 +1804,23 @@ export async function updateBiddingBasicInfo(
// 정의된 필드들만 업데이트
if (updates.title !== undefined) updateData.title = updates.title
if (updates.description !== undefined) updateData.description = updates.description
- if (updates.content !== undefined) updateData.content = updates.content
+ // content는 bidding 테이블에 컬럼이 없음, notice content는 별도로 저장해야 함
+ // if (updates.content !== undefined) updateData.content = updates.content
if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType
if (updates.contractType !== undefined) updateData.contractType = updates.contractType
+
+ // 입찰공고 내용 저장
+ if (updates.content !== undefined) {
+ try {
+ await saveBiddingNotice(biddingId, {
+ title: (updates.title || '') + ' 입찰공고', // 제목이 없으면 기존 제목을 가져오거나 해야하는데, 여기서는 업데이트된 제목 사용
+ content: updates.content
+ })
+ } catch (e) {
+ console.error('Failed to save bidding notice content:', e)
+ // 공고 저장 실패는 전체 업데이트 실패로 처리하지 않음 (로그만 남김)
+ }
+ }
if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType
if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom
if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount
@@ -1793,7 +1832,6 @@ export async function updateBiddingBasicInfo(
if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate)
if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate)
if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate)
- if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate)
if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting
if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument
if (updates.currency !== undefined) updateData.currency = updates.currency
@@ -1877,12 +1915,14 @@ export async function updateBiddingBasicInfo(
}
}
-// 입찰 일정 업데이트
+// 입찰 일정 업데이트 (오프셋 기반)
export async function updateBiddingSchedule(
biddingId: number,
schedule: {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -1913,14 +1953,28 @@ export async function updateBiddingSchedule(
return new Date(`${dateStr}:00+09:00`)
}
+ // 시간 문자열(HH:MM)을 임시 timestamp로 변환 (1970-01-01 HH:MM:00 UTC)
+ // 결재 완료 시 실제 날짜로 계산됨
+ const timeToTimestamp = (timeStr?: string): Date | null => {
+ if (!timeStr) return null
+ const [hours, minutes] = timeStr.split(':').map(Number)
+ const date = new Date(0) // 1970-01-01 00:00:00 UTC
+ date.setUTCHours(hours, minutes, 0, 0)
+ return date
+ }
+
return await db.transaction(async (tx) => {
const updateData: any = {
updatedAt: new Date(),
updatedBy: userName,
}
- if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate) || null
- if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate) || null
+ // 오프셋 기반 필드 저장
+ if (schedule.submissionStartOffset !== undefined) updateData.submissionStartOffset = schedule.submissionStartOffset
+ if (schedule.submissionDurationDays !== undefined) updateData.submissionDurationDays = schedule.submissionDurationDays
+ // 시간은 timestamp 필드에 임시 저장 (1970-01-01 HH:MM:00)
+ if (schedule.submissionStartTime !== undefined) updateData.submissionStartDate = timeToTimestamp(schedule.submissionStartTime)
+ if (schedule.submissionEndTime !== undefined) updateData.submissionEndDate = timeToTimestamp(schedule.submissionEndTime)
if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
@@ -2196,7 +2250,7 @@ export async function updateBiddingProjectInfo(biddingId: number) {
}
// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
-async function updateBiddingAmounts(biddingId: number) {
+export async function updateBiddingAmounts(biddingId: number) {
try {
// 해당 bidding의 모든 PR 아이템들의 금액 합계 계산
const amounts = await db
@@ -2214,9 +2268,9 @@ async function updateBiddingAmounts(biddingId: number) {
await db
.update(biddings)
.set({
- targetPrice: totalTargetAmount,
- budget: totalBudgetAmount,
- finalBidPrice: totalActualAmount,
+ targetPrice: String(totalTargetAmount),
+ budget: String(totalBudgetAmount),
+ finalBidPrice: String(totalActualAmount),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -2511,6 +2565,119 @@ export async function deleteBiddingCompanyContact(contactId: number) {
}
}
+// 입찰담당자별 입찰 업체 조회
+export async function getBiddingCompaniesByBidPicId(bidPicId: number) {
+ try {
+ const companies = await db
+ .select({
+ biddingId: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ biddingTitle: biddings.title,
+ companyId: biddingCompanies.companyId,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddings.bidPicId, bidPicId))
+ .orderBy(desc(biddings.updatedAt))
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies by bidPicId:', error)
+ return {
+ success: false,
+ error: '입찰 업체 조회에 실패했습니다.',
+ data: []
+ }
+ }
+}
+
+// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함)
+export async function addBiddingCompanyFromOtherBidding(
+ targetBiddingId: number,
+ sourceBiddingId: number,
+ companyId: number,
+ contacts?: Array<{
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }>
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 중복 체크
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, targetBiddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ if (existingCompany.length > 0) {
+ return {
+ success: false,
+ error: '이미 등록된 업체입니다.'
+ }
+ }
+
+ // 1. biddingCompanies 레코드 생성
+ const [biddingCompanyResult] = await tx
+ .insert(biddingCompanies)
+ .values({
+ biddingId: targetBiddingId,
+ companyId: companyId,
+ invitationStatus: 'pending',
+ invitedAt: new Date(),
+ })
+ .returning({ id: biddingCompanies.id })
+
+ if (!biddingCompanyResult) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ // 2. 담당자 정보 추가
+ if (contacts && contacts.length > 0) {
+ await tx.insert(biddingCompaniesContacts).values(
+ contacts.map(contact => ({
+ biddingId: targetBiddingId,
+ vendorId: companyId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ }))
+ )
+ }
+
+ // 3. company_condition_responses 레코드 생성
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyResult.id,
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: biddingCompanyResult.id }
+ }
+ })
+ } catch (error) {
+ console.error('Failed to add bidding company from other bidding:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
@@ -2758,10 +2925,13 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 2. 입찰번호 생성 (타입에 따라 다르게 처리)
let newBiddingNumber: string
+ let originalBiddingNumber: string
if (type === 'rebidding') {
// 재입찰: 완전히 새로운 입찰번호 생성
newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ // 재입찰시에도 원입찰번호는 새로 생성된 입찰번호로 셋팅
+ originalBiddingNumber = newBiddingNumber.split('-')[0]
} else {
// 차수증가: 기존 입찰번호에서 차수 증가
const currentBiddingNumber = existingBidding.biddingNumber
@@ -2771,16 +2941,18 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
let currentRound = match ? parseInt(match[1]) : 1
if (currentRound >= 3) {
- // -03 이상이면 새로운 번호 생성
+ // -03 이상이면 재입찰이며, 새로운 번호 생성
newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ // 새로 생성한 입찰번호를 원입찰번호로 셋팅
+ originalBiddingNumber = newBiddingNumber.split('-')[0]
} else {
// -02까지는 차수만 증가
const baseNumber = currentBiddingNumber.split('-')[0]
newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ // 차수증가의 경우에도 원입찰번호는 새로 생성한 입찰번호로 셋팅
+ originalBiddingNumber = newBiddingNumber.split('-')[0]
}
}
- //원입찰번호는 -0n 제외하고 저장
- const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0]
// 3. 새로운 입찰 생성 (기존 정보 복제)
const [newBidding] = await tx
@@ -2793,13 +2965,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 기본 정보 복제
projectName: existingBidding.projectName,
+ projectCode: existingBidding.projectCode, // 프로젝트 코드 복제
itemName: existingBidding.itemName,
title: existingBidding.title,
description: existingBidding.description,
// 계약 정보 복제
contractType: existingBidding.contractType,
- biddingType: existingBidding.biddingType,
+ noticeType: existingBidding.noticeType, // 공고타입 복제
+ biddingType: existingBidding.biddingType, // 구매유형 복제
awardCount: existingBidding.awardCount,
contractStartDate: existingBidding.contractStartDate,
contractEndDate: existingBidding.contractEndDate,
@@ -2809,7 +2983,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
biddingRegistrationDate: new Date(),
submissionStartDate: null,
submissionEndDate: null,
- evaluationDate: null,
// 사양설명회
hasSpecificationMeeting: existingBidding.hasSpecificationMeeting,
@@ -2819,6 +2992,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
budget: existingBidding.budget,
targetPrice: existingBidding.targetPrice,
targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria,
+ actualPrice: existingBidding.actualPrice,
finalBidPrice: null, // 최종입찰가는 초기화
// PR 정보 복제
@@ -2832,6 +3006,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 구매조직
purchasingOrganization: existingBidding.purchasingOrganization,
+ plant: existingBidding.plant,
// 담당자 정보 복제
bidPicId: existingBidding.bidPicId,
@@ -3074,8 +3249,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
.from(biddingDocuments)
.where(and(
eq(biddingDocuments.biddingId, biddingId),
- // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제)
- isNull(biddingDocuments.prItemId),
// SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제
or(
eq(biddingDocuments.documentType, 'evaluation_doc'),
@@ -3086,32 +3259,34 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
if (existingDocuments.length > 0) {
for (const doc of existingDocuments) {
try {
- // 기존 파일을 Buffer로 읽어서 File 객체 생성
- const { readFileSync, existsSync } = await import('fs')
+ // 기존 파일 경로 확인 및 Buffer로 읽기
+ const { readFile, access, constants } = await import('fs/promises')
const { join } = await import('path')
+ // 파일 경로 정규화
const oldFilePath = doc.filePath.startsWith('/uploads/')
? join(process.cwd(), 'public', doc.filePath)
+ : doc.filePath.startsWith('/')
+ ? join(process.cwd(), 'public', doc.filePath)
: doc.filePath
- if (!existsSync(oldFilePath)) {
- console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`)
+ // 파일 존재 여부 확인
+ try {
+ await access(oldFilePath, constants.R_OK)
+ } catch {
+ console.warn(`원본 파일이 존재하지 않거나 읽을 수 없음: ${oldFilePath}`)
continue
}
- // 파일 내용을 읽어서 Buffer 생성
- const fileBuffer = readFileSync(oldFilePath)
-
- // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션)
- const file = new File([fileBuffer], doc.fileName, {
- type: doc.mimeType || 'application/octet-stream'
- })
+ // 파일 내용을 Buffer로 읽기
+ const fileBuffer = await readFile(oldFilePath)
- // saveFile을 사용하여 새 파일 저장
- const saveResult = await saveFile({
- file,
+ // saveBuffer를 사용하여 새 파일 저장 (File 객체 변환 없이 직접 저장)
+ const saveResult = await saveBuffer({
+ buffer: fileBuffer,
+ fileName: doc.fileName,
directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`,
- originalName: `copied_${Date.now()}_${doc.fileName}`,
+ originalName: doc.originalFileName || doc.fileName,
userId: userName
})
@@ -3145,9 +3320,10 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
}
}
- revalidatePath('/bidding')
- revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신
- revalidatePath(`/bidding/${newBidding.id}`)
+ revalidatePath('/bid-receive')
+ revalidatePath('/evcp/bid-receive')
+ revalidatePath('/evcp/bid')
+ revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신
return {
success: true,
@@ -3436,9 +3612,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) {
// 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
basicConditions.push(
or(
- eq(biddings.status, 'bidding_closed'),
eq(biddings.status, 'evaluation_of_bidding'),
- eq(biddings.status, 'vendor_selected')
+ eq(biddings.status, 'vendor_selected'),
+ eq(biddings.status, 'round_increase'),
+ eq(biddings.status, 'rebidding'),
)!
)
@@ -3704,7 +3881,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
// 유찰 정보 (업데이트 일시를 유찰일로 사용)
disposalDate: biddings.updatedAt, // 유찰일
disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
- disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+ disposalUpdatedBy: users.name, // 폐찰수정자
// 폐찰 정보
closureReason: biddings.description, // 폐찰사유
@@ -3719,9 +3896,10 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
createdBy: biddings.createdBy,
createdAt: biddings.createdAt,
updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
+ updatedBy: users.name,
})
.from(biddings)
+ .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`)
.leftJoin(biddingDocuments, and(
eq(biddingDocuments.biddingId, biddings.id),
eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서
@@ -3791,4 +3969,378 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
console.error("Error in getBiddingsForFailure:", err)
return { data: [], pageCount: 0, total: 0 }
}
-} \ No newline at end of file
+}
+
+
+export async function getBiddingSelectionItemsAndPrices(biddingId: number) {
+ try {
+ const [prItems, vendorPrices] = await Promise.all([
+ getPrItemsForBidding(biddingId),
+ getVendorPricesForBidding(biddingId)
+ ])
+
+ return {
+ prItems,
+ vendorPrices
+ }
+ } catch (error) {
+ console.error('Failed to get bidding selection items and prices:', error)
+ throw error
+ }
+}
+
+// ========================================
+// 화학물질 조회 및 저장 관련 함수들
+// ========================================
+
+/**
+ * 입찰 참여업체의 화학물질 정보를 조회하고 DB에 저장
+ */
+// export async function checkAndSaveChemicalSubstanceForBiddingCompany(biddingCompanyId: number) {
+// try {
+// // 입찰 참여업체 정보 조회 (벤더 정보 포함)
+// const biddingCompanyInfo = await db
+// .select({
+// id: biddingCompanies.id,
+// biddingId: biddingCompanies.biddingId,
+// companyId: biddingCompanies.companyId,
+// hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+// vendors: {
+// vendorCode: vendors.vendorCode
+// }
+// })
+// .from(biddingCompanies)
+// .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+// .where(eq(biddingCompanies.id, biddingCompanyId))
+// .limit(1)
+
+// if (!biddingCompanyInfo[0]) {
+// throw new Error(`입찰 참여업체를 찾을 수 없습니다: ${biddingCompanyId}`)
+// }
+
+// const companyInfo = biddingCompanyInfo[0]
+
+// // 이미 화학물질 검사가 완료된 경우 스킵
+// if (companyInfo.hasChemicalSubstance !== null && companyInfo.hasChemicalSubstance !== undefined) {
+// console.log(`이미 화학물질 검사가 완료된 입찰 참여업체: ${biddingCompanyId}`)
+// return {
+// success: true,
+// message: '이미 화학물질 검사가 완료되었습니다.',
+// hasChemicalSubstance: companyInfo.hasChemicalSubstance
+// }
+// }
+
+// // 벤더 코드가 없는 경우 스킵
+// if (!companyInfo.vendors?.vendorCode) {
+// console.log(`벤더 코드가 없는 입찰 참여업체: ${biddingCompanyId}`)
+// return {
+// success: false,
+// message: '벤더 코드가 없습니다.'
+// }
+// }
+
+// // 입찰의 PR 아이템들 조회 (자재번호 있는 것만)
+// const prItems = await db
+// .select({
+// id: prItemsForBidding.id,
+// materialNumber: prItemsForBidding.materialNumber
+// })
+// .from(prItemsForBidding)
+// .where(and(
+// eq(prItemsForBidding.biddingId, companyInfo.biddingId),
+// isNotNull(prItemsForBidding.materialNumber),
+// sql`${prItemsForBidding.materialNumber} != ''`
+// ))
+
+// if (prItems.length === 0) {
+// console.log(`자재번호가 있는 PR 아이템이 없는 입찰: ${companyInfo.biddingId}`)
+// return {
+// success: false,
+// message: '조회할 자재가 없습니다.'
+// }
+// }
+
+// // 각 자재에 대해 화학물질 조회
+// let hasAnyChemicalSubstance = false
+// const results: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = []
+
+// for (const prItem of prItems) {
+// try {
+// const checkResult = await checkChemicalSubstance({
+// bukrs: 'H100', // 회사코드는 H100 고정
+// werks: 'PM11', // WERKS는 PM11 고정
+// lifnr: companyInfo.vendors.vendorCode,
+// matnr: prItem.materialNumber!
+// })
+
+// if (checkResult.success) {
+// const itemHasChemical = checkResult.hasChemicalSubstance || false
+// hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical
+
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: itemHasChemical,
+// message: checkResult.message || '조회 성공'
+// })
+// } else {
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: false,
+// message: checkResult.message || '조회 실패'
+// })
+// }
+
+// // API 호출 간 지연
+// await new Promise(resolve => setTimeout(resolve, 500))
+
+// } catch (error) {
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: false,
+// message: error instanceof Error ? error.message : 'Unknown error'
+// })
+// }
+// }
+
+// // 하나라도 Y(Y=true)이면 true, 모두 N(false)이면 false
+// const finalHasChemicalSubstance = hasAnyChemicalSubstance
+
+// // DB에 결과 저장
+// await db
+// .update(biddingCompanies)
+// .set({
+// hasChemicalSubstance: finalHasChemicalSubstance,
+// updatedAt: new Date()
+// })
+// .where(eq(biddingCompanies.id, biddingCompanyId))
+
+// console.log(`화학물질 정보 저장 완료: 입찰 참여업체 ${biddingCompanyId}, 화학물질 ${finalHasChemicalSubstance ? '있음' : '없음'} (${results.filter(r => r.hasChemicalSubstance).length}/${results.length})`)
+
+// return {
+// success: true,
+// message: `화학물질 조회 및 저장이 완료되었습니다. (${results.filter(r => r.hasChemicalSubstance).length}/${results.length}개 자재에 화학물질 있음)`,
+// hasChemicalSubstance: finalHasChemicalSubstance,
+// results
+// }
+
+// } catch (error) {
+// console.error(`화학물질 조회 실패 (입찰 참여업체 ${biddingCompanyId}):`, error)
+// return {
+// success: false,
+// message: error instanceof Error ? error.message : 'Unknown error',
+// hasChemicalSubstance: null,
+// results: []
+// }
+// }
+// }
+
+/**
+ * 입찰의 모든 참여업체에 대한 화학물질 정보를 일괄 조회하고 저장
+ */
+export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) {
+ try {
+ const [biddingInfo] = await db
+ .select({
+ id: biddings.id,
+ ANFNR: biddings.ANFNR,
+ plant: biddings.plant,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!biddingInfo) {
+ return {
+ success: false,
+ message: '입찰 정보를 찾을 수 없습니다.',
+ results: []
+ }
+ }
+
+ if (!biddingInfo.ANFNR) {
+ return {
+ success: true,
+ message: 'SAP PR 연동 입찰이 아니므로 화학물질 검사를 건너뜁니다.',
+ results: []
+ }
+ }
+
+ const biddingWerks = biddingInfo.plant?.trim()
+ if (!biddingWerks) {
+ return {
+ success: false,
+ message: '입찰의 플랜트(WERKS) 정보가 없어 화학물질 검사를 진행할 수 없습니다.',
+ results: []
+ }
+ }
+
+ // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만)
+ const biddingCompaniesList = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+ vendors: {
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName
+ }
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ isNotNull(vendors.vendorCode),
+ sql`${vendors.vendorCode} != ''`
+ ))
+
+ if (biddingCompaniesList.length === 0) {
+ return {
+ success: true,
+ message: '벤더 코드가 있는 참여업체가 없습니다.',
+ results: []
+ }
+ }
+
+ // 입찰의 PR 아이템들 조회 (자재번호 있는 것만)
+ const prItems = await db
+ .select({
+ materialNumber: prItemsForBidding.materialNumber
+ })
+ .from(prItemsForBidding)
+ .where(and(
+ eq(prItemsForBidding.biddingId, biddingId),
+ isNotNull(prItemsForBidding.materialNumber),
+ sql`${prItemsForBidding.materialNumber} != ''`
+ ))
+
+ if (prItems.length === 0) {
+ return {
+ success: false,
+ message: '조회할 자재가 없습니다.',
+ results: []
+ }
+ }
+
+ const materialNumbers = prItems.map(item => item.materialNumber!).filter(Boolean)
+
+ // 각 참여업체에 대해 화학물질 조회
+ const results: Array<{
+ biddingCompanyId: number;
+ vendorCode: string;
+ vendorName: string;
+ success: boolean;
+ hasChemicalSubstance?: boolean;
+ message: string;
+ materialResults?: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }>;
+ }> = []
+
+ for (const biddingCompany of biddingCompaniesList) {
+ try {
+ // 이미 검사가 완료된 경우 스킵
+ if (biddingCompany.hasChemicalSubstance !== null && biddingCompany.hasChemicalSubstance !== undefined) {
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: true,
+ hasChemicalSubstance: biddingCompany.hasChemicalSubstance,
+ message: '이미 검사가 완료되었습니다.'
+ })
+ continue
+ }
+
+ // 각 자재에 대해 화학물질 조회
+ let hasAnyChemicalSubstance = false
+ const materialResults: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = []
+
+ for (const materialNumber of materialNumbers) {
+ try {
+ const checkResult = await checkChemicalSubstance({
+ bukrs: 'H100', // 회사코드는 H100 고정
+ werks: biddingWerks,
+ lifnr: biddingCompany.vendors!.vendorCode!,
+ matnr: materialNumber
+ })
+
+ if (checkResult.success) {
+ const itemHasChemical = checkResult.hasChemicalSubstance || false
+ hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical
+
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: itemHasChemical,
+ message: checkResult.message || '조회 성공'
+ })
+ } else {
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: false,
+ message: checkResult.message || '조회 실패'
+ })
+ }
+
+ // API 호출 간 지연
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ } catch (error) {
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+
+ // 하나라도 Y이면 true, 모두 N이면 false
+ const finalHasChemicalSubstance = hasAnyChemicalSubstance
+
+ // DB에 결과 저장
+ await db
+ .update(biddingCompanies)
+ .set({
+ hasChemicalSubstance: finalHasChemicalSubstance,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompany.id))
+
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: true,
+ hasChemicalSubstance: finalHasChemicalSubstance,
+ message: `조회 완료 (${materialResults.filter(r => r.hasChemicalSubstance).length}/${materialResults.length}개 자재에 화학물질 있음)`,
+ materialResults
+ })
+
+ } catch (error) {
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length
+ const totalCount = results.length
+
+ console.log(`입찰 ${biddingId} 화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`)
+
+ return {
+ success: true,
+ message: `화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`,
+ results
+ }
+
+ } catch (error) {
+ console.error(`입찰 화학물질 일괄 조회 실패 (${biddingId}):`, error)
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ results: []
+ }
+ }
+}
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 73c2fe21..3254ae7e 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -99,7 +99,6 @@ export const createBiddingSchema = z.object({
submissionEndDate: z.string().optional(),
- evaluationDate: z.string().optional(),
// 회의 및 문서
hasSpecificationMeeting: z.boolean().default(false),
@@ -220,7 +219,6 @@ export const createBiddingSchema = z.object({
submissionStartDate: z.string().optional(),
submissionEndDate: z.string().optional(),
- evaluationDate: z.string().optional(),
hasSpecificationMeeting: z.boolean().optional(),
hasPrDocument: z.boolean().optional(),
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 7dd8384e..6910e360 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -4,7 +4,17 @@ import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import {
Table,
@@ -16,10 +26,12 @@ import {
} from '@/components/ui/table'
import {
Package,
-
Download,
- Calculator
+ Calculator,
+ CalendarIcon
} from 'lucide-react'
+import { format } from 'date-fns'
+import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/utils'
import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download'
import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
@@ -186,6 +198,8 @@ export function PrItemsPricingTable({
}: PrItemsPricingTableProps) {
const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
+ const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false)
+ const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState<Date | undefined>(undefined)
// 초기 견적 데이터 설정 및 SPEC 문서 로드
React.useEffect(() => {
@@ -279,6 +293,21 @@ export function PrItemsPricingTable({
onTotalAmountChange(totalAmount)
}
+ // 일괄 납기일 적용
+ const applyBulkDeliveryDate = () => {
+ if (bulkDeliveryDate && quotations.length > 0) {
+ const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd')
+ const updatedQuotations = quotations.map(q => ({
+ ...q,
+ proposedDeliveryDate: formattedDate
+ }))
+
+ setQuotations(updatedQuotations)
+ onQuotationsChange(updatedQuotations)
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }
+ }
// 통화 포맷팅
const formatCurrency = (amount: number) => {
@@ -292,12 +321,26 @@ export function PrItemsPricingTable({
const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
return (
+ <>
<Card>
<CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Package className="w-5 h-5" />
- 품목별 입찰 작성
- </CardTitle>
+ <div className="flex items-center justify-between">
+ <CardTitle className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 품목별 입찰 작성
+ </CardTitle>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowBulkDateDialog(true)}
+ >
+ <CalendarIcon className="h-4 w-4 mr-1" />
+ 전체 납품예정일 설정
+ </Button>
+ )}
+ </div>
</CardHeader>
<CardContent>
<div className="space-y-4">
@@ -382,18 +425,14 @@ export function PrItemsPricingTable({
</span>
) : (
<Input
- type="number"
- inputMode="decimal"
- min={0}
- pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$"
- value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice}
+ type="text"
+ inputMode="numeric"
+ value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice.toLocaleString()}
onChange={(e) => {
- let value = e.target.value
- if (/^0[0-9]+/.test(value)) {
- value = value.replace(/^0+/, '')
- if (value === '') value = '0'
- }
- const numericValue = parseFloat(value)
+ // 콤마 제거 및 숫자만 허용
+ const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '')
+ const numericValue = Number(value)
+
updateQuotation(
item.id,
'bidUnitPrice',
@@ -471,5 +510,73 @@ export function PrItemsPricingTable({
</div>
</CardContent>
</Card>
+
+ {/* 일괄 납품예정일 설정 다이얼로그 */}
+ <Dialog open={showBulkDateDialog} onOpenChange={setShowBulkDateDialog}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>전체 납품예정일 설정</DialogTitle>
+ <DialogDescription>
+ 모든 PR 아이템에 동일한 납품예정일을 적용합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <Label>납품예정일 선택</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !bulkDeliveryDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={bulkDeliveryDate}
+ onSelect={setBulkDeliveryDate}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="bg-muted/50 rounded-lg p-3">
+ <p className="text-sm text-muted-foreground">
+ 선택된 날짜가 <strong>{prItems.length}개</strong>의 모든 PR 아이템에 적용됩니다.
+ 기존에 설정된 납품예정일은 모두 교체됩니다.
+ </p>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={applyBulkDeliveryDate}
+ disabled={!bulkDeliveryDate}
+ >
+ 전체 적용
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
)
}
diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
new file mode 100644
index 00000000..e1d985fe
--- /dev/null
+++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
@@ -0,0 +1,275 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { PartnersBiddingListItem } from '../detail/service'
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+/**
+ * Partners 입찰 목록을 Excel로 내보내기
+ * - 계약구분, 진행상태는 라벨(명칭)로 변환
+ * - 입찰기간은 submissionStartDate, submissionEndDate 기준
+ * - 날짜는 적절한 형식으로 변환
+ */
+export async function exportPartnersBiddingsToExcel(
+ table: Table<PartnersBiddingListItem>,
+ {
+ filename = "협력업체입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // select, actions, attachments 컬럼 제외
+ const columns = allColumns.filter(
+ (col) => !["select", "actions", "attachments"].includes(col.id)
+ )
+
+ // 헤더 매핑 (컬럼 id -> Excel 헤더명)
+ const headerMap: Record<string, string> = {
+ biddingNumber: "입찰 No.",
+ status: "입찰상태",
+ isUrgent: "긴급여부",
+ title: "입찰명",
+ isAttendingMeeting: "사양설명회",
+ isBiddingParticipated: "입찰 참여의사",
+ biddingSubmissionStatus: "입찰 제출여부",
+ contractType: "계약구분",
+ submissionStartDate: "입찰기간",
+ contractStartDate: "계약기간",
+ bidPicName: "입찰담당자",
+ supplyPicName: "조달담당자",
+ updatedAt: "최종수정일",
+ }
+
+ // 헤더 행 생성
+ const headerRow = columns.map((col) => {
+ return headerMap[col.id] || col.id
+ })
+
+ // 데이터 행 생성
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) => {
+ const original = row.original
+ return columns.map((col) => {
+ const colId = col.id
+ let value: any
+
+ // 특별 처리 필요한 컬럼들
+ switch (colId) {
+ case "contractType":
+ // 계약구분: 라벨로 변환
+ value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType
+ break
+
+ case "status":
+ // 입찰상태: 라벨로 변환
+ value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status
+ break
+
+ case "isUrgent":
+ // 긴급여부: Yes/No
+ value = original.isUrgent ? "긴급" : "일반"
+ break
+
+ case "isAttendingMeeting":
+ // 사양설명회: 참석/불참/미결정
+ if (original.isAttendingMeeting === null) {
+ value = "해당없음"
+ } else {
+ value = original.isAttendingMeeting ? "참석" : "불참"
+ }
+ break
+
+ case "isBiddingParticipated":
+ // 입찰 참여의사: 참여/불참/미결정
+ if (original.isBiddingParticipated === null) {
+ value = "미결정"
+ } else {
+ value = original.isBiddingParticipated ? "참여" : "불참"
+ }
+ break
+
+ case "biddingSubmissionStatus":
+ // 입찰 제출여부: 최종제출/제출/미제출
+ const finalQuoteAmount = original.finalQuoteAmount
+ const isFinalSubmission = original.isFinalSubmission
+
+ if (!finalQuoteAmount) {
+ value = "미제출"
+ } else if (isFinalSubmission) {
+ value = "최종제출"
+ } else {
+ value = "제출"
+ }
+ break
+
+ case "submissionStartDate":
+ // 입찰기간: submissionStartDate, submissionEndDate 기준
+ const startDate = original.submissionStartDate
+ const endDate = original.submissionEndDate
+
+ if (!startDate || !endDate) {
+ value = "-"
+ } else {
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
+ value = `${formatValue(startObj)} ~ ${formatValue(endObj)}`
+ }
+ break
+
+ // case "preQuoteDeadline":
+ // // 사전견적 마감일: 날짜 형식
+ // if (!original.preQuoteDeadline) {
+ // value = "-"
+ // } else {
+ // const deadline = new Date(original.preQuoteDeadline)
+ // value = deadline.toISOString().slice(0, 16).replace('T', ' ')
+ // }
+ // break
+
+ case "contractStartDate":
+ // 계약기간: contractStartDate, contractEndDate 기준
+ const contractStart = original.contractStartDate
+ const contractEnd = original.contractEndDate
+
+ if (!contractStart || !contractEnd) {
+ value = "-"
+ } else {
+ const startObj = new Date(contractStart)
+ const endObj = new Date(contractEnd)
+ value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}`
+ }
+ break
+
+ case "bidPicName":
+ // 입찰담당자: bidPicName
+ value = original.bidPicName || "-"
+ break
+
+ case "supplyPicName":
+ // 조달담당자: supplyPicName
+ value = original.supplyPicName || "-"
+ break
+
+ case "updatedAt":
+ // 최종수정일: 날짜 시간 형식
+ if (original.updatedAt) {
+ const updated = new Date(original.updatedAt)
+ value = updated.toISOString().slice(0, 16).replace('T', ' ')
+ } else {
+ value = "-"
+ }
+ break
+
+ case "biddingNumber":
+ // 입찰번호: 원입찰번호 포함
+ const biddingNumber = original.biddingNumber
+ const originalBiddingNumber = original.originalBiddingNumber
+ if (originalBiddingNumber) {
+ value = `${biddingNumber} (원: ${originalBiddingNumber})`
+ } else {
+ value = biddingNumber
+ }
+ break
+
+ default:
+ // 기본값: row.getValue 사용
+ value = row.getValue(colId)
+
+ // null/undefined 처리
+ if (value == null) {
+ value = ""
+ }
+
+ // 객체인 경우 JSON 문자열로 변환
+ if (typeof value === "object") {
+ value = JSON.stringify(value)
+ }
+ break
+ }
+
+ return value
+ })
+ })
+
+ // 최종 sheetData
+ const sheetData = [headerRow, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, columns.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
index d0ef97f1..8d6cb82d 100644
--- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps {
title: string
preQuoteDate: string | null
biddingRegistrationDate: string | null
- evaluationDate: string | null
hasSpecificationMeeting?: boolean // 사양설명회 여부 추가
} | null
biddingCompanyId: number
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index bf76de62..bf33cef5 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -75,7 +75,6 @@ interface BiddingDetail {
biddingRegistrationDate: Date | string | null
submissionStartDate: Date | string | null
submissionEndDate: Date | string | null
- evaluationDate: Date | string | null
currency: string
budget: number | null
targetPrice: number | null
@@ -869,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const timeLeft = deadline.getTime() - now.getTime()
const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
- const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시
+ const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className={`p-3 rounded-lg border-2 ${
@@ -884,7 +884,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Calendar className="w-5 h-5" />
<span className="font-medium">제출 마감일:</span>
<span className="text-lg font-semibold">
- {kstDeadline}
+ {displayDeadline}
</span>
</div>
{isExpired ? (
@@ -921,17 +921,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<span className="font-medium">입찰서 제출기간:</span> {(() => {
const start = new Date(biddingDetail.submissionStartDate!)
const end = new Date(biddingDetail.submissionEndDate!)
- const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- return `${kstStart} ~ ${kstEnd}`
+ const displayStart = start.toISOString().slice(0, 16).replace('T', ' ')
+ const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ')
+ return `${displayStart} ~ ${displayEnd}`
})()}
</div>
)}
- {biddingDetail.evaluationDate && (
- <div>
- <span className="font-medium">평가일:</span> {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")}
- </div>
- )}
+
</div>
</div>
</CardContent>
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index a122e87b..09c3caad 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
cell: ({ row }) => {
const isAttending = row.original.isAttendingMeeting
if (isAttending === null) {
- return <div className="text-muted-foreground text-center">-</div>
+ return <div className="text-muted-foreground text-center">해당없음</div>
}
return isAttending ? (
<CheckCircle className="h-5 w-5 text-green-600 mx-auto" />
@@ -352,45 +352,45 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-sm">
- <div>{formatKst(startObj)}</div>
+ <div>{formatValue(startObj)}</div>
<div className="text-muted-foreground">~</div>
- <div>{formatKst(endObj)}</div>
+ <div>{formatValue(endObj)}</div>
</div>
)
},
}),
// 사전견적 마감일
- columnHelper.accessor('preQuoteDeadline', {
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return <div className="text-muted-foreground">-</div>
- }
+ // columnHelper.accessor('preQuoteDeadline', {
+ // header: '사전견적 마감일',
+ // cell: ({ row }) => {
+ // const deadline = row.original.preQuoteDeadline
+ // if (!deadline) {
+ // return <div className="text-muted-foreground">-</div>
+ // }
- const now = new Date()
- const deadlineDate = new Date(deadline)
- const isExpired = deadlineDate < now
+ // const now = new Date()
+ // const deadlineDate = new Date(deadline)
+ // const isExpired = deadlineDate < now
- return (
- <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}>
- <Calendar className="w-4 h-4" />
- <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span>
- {isExpired && (
- <Badge variant="destructive" className="text-xs">
- 마감
- </Badge>
- )}
- </div>
- )
- },
- }),
+ // return (
+ // <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}>
+ // <Calendar className="w-4 h-4" />
+ // <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span>
+ // {isExpired && (
+ // <Badge variant="destructive" className="text-xs">
+ // 마감
+ // </Badge>
+ // )}
+ // </div>
+ // )
+ // },
+ // }),
// 계약기간
columnHelper.accessor('contractStartDate', {
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index 0f68ed68..f1cb0bdc 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) {
title: rowAction.row.original.title,
preQuoteDate: null,
biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null,
- evaluationDate: null,
hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false,
} : null}
biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0}
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
index 87b1367e..9a2f026c 100644
--- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -2,10 +2,12 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Users} from "lucide-react"
+import { Users, FileSpreadsheet } from "lucide-react"
+import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { PartnersBiddingListItem } from '../detail/service'
+import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel'
interface PartnersBiddingToolbarActionsProps {
table: Table<PartnersBiddingListItem>
@@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null
+ const [isExporting, setIsExporting] = React.useState(false)
+
const handleSpecificationMeetingClick = () => {
if (selectedBidding && setRowAction) {
setRowAction({
@@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({
}
}
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ try {
+ setIsExporting(true)
+ await exportPartnersBiddingsToExcel(table, {
+ filename: "협력업체입찰목록",
+ onlySelected: false,
+ })
+ toast.success("Excel 파일이 다운로드되었습니다.")
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel 내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }, [table])
+
return (
<div className="flex items-center gap-2">
+ {/* Excel 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isExporting}
+ className="gap-2"
+ >
+ <FileSpreadsheet className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span>
+ </Button>
<Button
variant="outline"
size="sm"
diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts
index 501c6cb0..fe246956 100644
--- a/lib/dolce/actions.ts
+++ b/lib/dolce/actions.ts
@@ -20,6 +20,7 @@ export interface DwgReceiptItem {
CreateUserENM: string | null;
CreateUserId: string | null;
CreateUserNo: string;
+ DetailDwgCNT: number;
Discipline: string;
DrawingKind: string;
DrawingMoveGbn: string;
@@ -44,6 +45,7 @@ export interface GttDwgReceiptItem {
CreateUserENM: string;
CreateUserId: string;
CreateUserNo: string;
+ DetailDwgCNT: number;
DGbn: string | null;
DegreeGbn: string | null;
DeptGbn: string | null;
@@ -946,7 +948,7 @@ export async function prepareB4DetailDrawingsV2(params: {
DrawingRevNo: revNo,
Category: category,
Receiver: null,
- Manager: drawingInfo.Manager || "970043",
+ Manager: drawingInfo.ManagerNo,
RegisterDesc: "",
UploadId: uploadId,
RegCompanyCode: vendorCode,
@@ -1188,7 +1190,7 @@ export async function bulkUploadB4FilesV2(
DrawingRevNo: revNo,
Category: category,
Receiver: null,
- Manager: drawingInfo.Manager || "970043",
+ Manager: drawingInfo.ManagerNo,
RegisterDesc: registerDesc,
UploadId: uploadId,
RegCompanyCode: vendorCode,
diff --git a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
index d4318b90..2d2532d7 100644
--- a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
+++ b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
@@ -272,7 +272,7 @@ export function AddAndModifyDetailDrawingDialog({
DrawingRevNo: drawingUsage === "CMT" ? null : revision,
Category: "TS", // To SHI (벤더가 SHI에게 제출)
Receiver: null,
- Manager: "",
+ Manager: drawing.ManagerNo,
RegisterDesc: comment,
UploadId: uploadId,
RegCompanyCode: vendorCode,
diff --git a/lib/dolce/table/drawing-list-columns.tsx b/lib/dolce/table/drawing-list-columns.tsx
index 30b71d8d..6fe1b3e2 100644
--- a/lib/dolce/table/drawing-list-columns.tsx
+++ b/lib/dolce/table/drawing-list-columns.tsx
@@ -28,12 +28,21 @@ export function drawingListColumns(lng: string, t: any): ColumnDef<DwgReceiptIte
minSize: 120,
},
{
+ accessorKey: "DetailDwgCNT",
+ header: t("drawingList.columns.detailDwgCnt"),
+ minSize: 100,
+ cell: ({ row }) => {
+ const count = row.getValue("DetailDwgCNT") as number;
+ return <div className="text-center">{count || 0}</div>;
+ },
+ },
+ {
accessorKey: "Manager",
header: t("drawingList.columns.manager"),
minSize: 200,
cell: ({ row }) => {
const managerENM = row.original.ManagerENM;
- const manager = row.getValue("Manager");
+ const manager = row.getValue("Manager") as string;
return <div>{managerENM || manager}</div>;
},
},
diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx
index 94d4d7d1..c76fcba0 100644
--- a/lib/dolce/table/gtt-drawing-list-columns.tsx
+++ b/lib/dolce/table/gtt-drawing-list-columns.tsx
@@ -41,12 +41,21 @@ export function createGttDrawingListColumns({
minSize: 80,
},
{
+ accessorKey: "DetailDwgCNT",
+ header: t("drawingList.columns.detailDwgCnt"),
+ minSize: 100,
+ cell: ({ row }) => {
+ const count = row.getValue("DetailDwgCNT") as number;
+ return <div className="text-center">{count || 0}</div>;
+ },
+ },
+ {
accessorKey: "Manager",
header: t("drawingList.columns.manager"),
minSize: 200,
cell: ({ row }) => {
const managerENM = row.original.ManagerENM;
- const manager = row.getValue("Manager");
+ const manager = row.getValue("Manager") as string;
return <div>{managerENM || manager}</div>;
},
},
@@ -56,7 +65,7 @@ export function createGttDrawingListColumns({
minSize: 120,
cell: ({ row }) => {
- const drawingMoveGbn = row.getValue("DrawingMoveGbn");
+ const drawingMoveGbn = row.getValue("DrawingMoveGbn") as string;
let type = "";
if (drawingMoveGbn == "도면입수") {
diff --git a/lib/forms-plant/services.ts b/lib/forms-plant/services.ts
index 3f50bd47..64d353de 100644
--- a/lib/forms-plant/services.ts
+++ b/lib/forms-plant/services.ts
@@ -21,7 +21,7 @@ import {
VendorDataReportTempsPlant,
} from "@/db/schema/vendorData";
import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm";
-import { unstable_cache } from "next/cache";
+import { unstable_cache ,unstable_noStore } from "next/cache";
import { revalidateTag } from "next/cache";
import { getErrorMessage } from "../handle-error";
import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns";
@@ -234,9 +234,10 @@ export async function getEditableFieldsByTag(
* 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱.
*/
export async function getFormData(formCode: string, projectCode: string, packageCode:string) {
+ unstable_noStore();
try {
- console.log(formCode,projectCode, packageCode)
+ // console.log(formCode,projectCode, packageCode)
const project = await db.query.projects.findFirst({
where: eq(projects.code, projectCode),
@@ -329,83 +330,84 @@ export async function getFormData(formCode: string, projectCode: string, package
console.error(`[getFormData] Cache operation failed:`, cacheError);
// Fallback logic (기존과 동일하게 editableFieldsMap 추가)
- try {
- console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`);
-
- const project = await db.query.projects.findFirst({
- where: eq(projects.code, projectCode),
- columns: {
- id: true
- }
- });
-
- const projectId = project.id;
-
- const metaRows = await db
- .select()
- .from(formMetas)
- .where(
- and(
- eq(formMetas.formCode, formCode),
- eq(formMetas.projectId, projectId)
- )
- )
- .orderBy(desc(formMetas.updatedAt))
- .limit(1);
-
- const meta = metaRows[0] ?? null;
- if (!meta) {
- console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
- return { columns: null, data: [], editableFieldsMap: new Map() };
- }
-
- const entryRows = await db
- .select()
- .from(formEntriesPlant)
- .where(
- and(
- eq(formEntriesPlant.formCode, formCode),
- eq(formEntriesPlant.projectCode, projectCode),
- eq(formEntriesPlant.packageCode, packageCode)
- )
- )
- .orderBy(desc(formEntriesPlant.updatedAt))
- .limit(1);
-
- const entry = entryRows[0] ?? null;
-
- let columns = meta.columns as DataTableColumnJSON[];
- const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
- columns = columns.filter(col => !excludeKeys.includes(col.key));
-
- columns.forEach((col) => {
- if (!col.displayLabel) {
- if (col.uom) {
- col.displayLabel = `${col.label} (${col.uom})`;
- } else {
- col.displayLabel = col.label;
- }
- }
- });
-
- let data: Array<Record<string, any>> = [];
- if (entry) {
- if (Array.isArray(entry.data)) {
- data = entry.data;
- } else {
- console.warn("formEntries data was not an array. Using empty array (fallback).");
- }
- }
-
- // Fallback에서도 편집 가능 필드 정보 계산
- const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId);
-
- return { columns, data, projectId, editableFieldsMap };
- } catch (dbError) {
- console.error(`[getFormData] Fallback DB query failed:`, dbError);
- return { columns: null, data: [], editableFieldsMap: new Map() };
- }
- }
+ // try {
+ // console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`);
+
+ // const project = await db.query.projects.findFirst({
+ // where: eq(projects.code, projectCode),
+ // columns: {
+ // id: true
+ // }
+ // });
+
+ // const projectId = project.id;
+
+ // const metaRows = await db
+ // .select()
+ // .from(formMetas)
+ // .where(
+ // and(
+ // eq(formMetas.formCode, formCode),
+ // eq(formMetas.projectId, projectId)
+ // )
+ // )
+ // .orderBy(desc(formMetas.updatedAt))
+ // .limit(1);
+
+ // const meta = metaRows[0] ?? null;
+ // if (!meta) {
+ // console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
+ // return { columns: null, data: [], editableFieldsMap: new Map() };
+ // }
+
+ // const entryRows = await db
+ // .select()
+ // .from(formEntriesPlant)
+ // .where(
+ // and(
+ // eq(formEntriesPlant.formCode, formCode),
+ // eq(formEntriesPlant.projectCode, projectCode),
+ // eq(formEntriesPlant.packageCode, packageCode)
+ // )
+ // )
+ // .orderBy(desc(formEntriesPlant.updatedAt))
+ // .limit(1);
+
+ // const entry = entryRows[0] ?? null;
+
+ // let columns = meta.columns as DataTableColumnJSON[];
+ // const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
+ // columns = columns.filter(col => !excludeKeys.includes(col.key));
+
+ // columns.forEach((col) => {
+ // if (!col.displayLabel) {
+ // if (col.uom) {
+ // col.displayLabel = `${col.label} (${col.uom})`;
+ // } else {
+ // col.displayLabel = col.label;
+ // }
+ // }
+ // });
+
+ // let data: Array<Record<string, any>> = [];
+ // if (entry) {
+ // if (Array.isArray(entry.data)) {
+ // data = entry.data;
+ // } else {
+ // console.warn("formEntries data was not an array. Using empty array (fallback).");
+ // }
+ // }
+
+ // // Fallback에서도 편집 가능 필드 정보 계산
+ // const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId);
+
+ // return { columns, data, projectId, editableFieldsMap };
+ // } catch (dbError) {
+ // console.error(`[getFormData] Fallback DB query failed:`, dbError);
+ // return { columns: null, data: [], editableFieldsMap: new Map() };
+ // }
+ // }
+}
}
/**
* contractId와 formCode(itemCode)를 사용하여 contractItemId를 찾는 서버 액션
@@ -1052,6 +1054,7 @@ type GetReportFileList = (
}>;
export const getFormId: GetReportFileList = async (projectCode, packageCode, formCode, mode) => {
+ unstable_noStore();
const result: { formId: number } = {
formId: 0,
};
diff --git a/lib/general-contracts/approval-actions.ts b/lib/general-contracts/approval-actions.ts
new file mode 100644
index 00000000..e75d6cd6
--- /dev/null
+++ b/lib/general-contracts/approval-actions.ts
@@ -0,0 +1,136 @@
+/**
+ * 일반계약 관련 결재 서버 액션
+ *
+ * 사용자가 UI에서 호출하는 함수들
+ * ApprovalSubmissionSaga를 사용하여 결재 프로세스를 시작
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapContractToApprovalTemplateVariables } from './approval-template-variables';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+import { users } from '@/db/schema';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+ pdfPath?: string;
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>;
+}
+
+/**
+ * 결재를 거쳐 일반계약 승인 요청을 처리하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestContractApprovalWithApproval({
+ * contractId: 123,
+ * contractSummary: summaryData,
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003'],
+ * title: '계약 체결 진행 품의 요청서'
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function requestContractApprovalWithApproval(data: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+ title?: string; // 결재 제목 (선택사항, 미지정 시 자동 생성)
+}) {
+ debugLog('[ContractApproval] 일반계약 승인 요청 결재 서버 액션 시작', {
+ contractId: data.contractId,
+ contractNumber: data.contractSummary.basicInfo?.contractNumber,
+ contractName: data.contractSummary.basicInfo?.name,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[ContractApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.contractId) {
+ debugError('[ContractApproval] 계약 ID 없음');
+ throw new Error('계약 ID가 필요합니다');
+ }
+
+ // 1. 유저의 nonsapUserId 조회 (Cronjob 환경을 위해)
+ debugLog('[ContractApproval] nonsapUserId 조회');
+ const userResult = await db.query.users.findFirst({
+ where: eq(users.id, data.currentUser.id),
+ columns: { nonsapUserId: true }
+ });
+ const nonsapUserId = userResult?.nonsapUserId || null;
+ debugLog('[ContractApproval] nonsapUserId 조회 완료', { nonsapUserId });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[ContractApproval] 템플릿 변수 매핑 시작');
+ const variables = await mapContractToApprovalTemplateVariables(data.contractSummary);
+ debugLog('[ContractApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 3. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[ContractApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'general_contract_approval',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ contractId: data.contractId,
+ contractSummary: data.contractSummary,
+ currentUser: {
+ id: data.currentUser.id,
+ email: data.currentUser.email,
+ nonsapUserId: nonsapUserId,
+ },
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: data.title || `계약 체결 진행 품의 요청서 - ${data.contractSummary.basicInfo?.contractNumber || data.contractId}`,
+ description: `${data.contractSummary.basicInfo?.name || '일반계약'} 계약 체결 진행 품의 요청`,
+ templateName: '일반계약 결재', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[ContractApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ // 4. 결재 상신 성공 시 상태를 'approval_in_progress'로 변경
+ if (result.status === 'pending_approval') {
+ debugLog('[ContractApproval] 상태를 approval_in_progress로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'approval_in_progress',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, data.contractId));
+ }
+
+ debugSuccess('[ContractApproval] 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ status: result.status,
+ });
+
+ return result;
+}
+
diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts
new file mode 100644
index 00000000..710e6101
--- /dev/null
+++ b/lib/general-contracts/approval-template-variables.ts
@@ -0,0 +1,345 @@
+/**
+ * 일반계약 결재 템플릿 변수 매핑 함수
+ *
+ * 제공된 HTML 템플릿의 변수명에 맞춰 매핑
+ */
+
+'use server';
+
+import { format } from 'date-fns';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param contractSummary - 계약 요약 정보
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapContractToApprovalTemplateVariables(
+ contractSummary: ContractSummary
+): Promise<Record<string, string>> {
+ const { basicInfo, items, subcontractChecklist } = contractSummary;
+
+ // 날짜 포맷팅 헬퍼
+ const formatDate = (date: any) => {
+ if (!date) return '';
+ try {
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return String(date);
+ return format(d, 'yyyy-MM-dd');
+ } catch {
+ return String(date || '');
+ }
+ };
+
+ // 금액 포맷팅 헬퍼
+ const formatCurrency = (amount: any) => {
+ if (amount === undefined || amount === null || amount === '') return '';
+ const num = Number(amount);
+ if (isNaN(num)) return String(amount);
+ return num.toLocaleString('ko-KR');
+ };
+
+ // 계약기간 포맷팅
+ const contractPeriod = basicInfo.startDate && basicInfo.endDate
+ ? `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}`
+ : '';
+
+ // 계약체결방식
+ const contractExecutionMethod = basicInfo.executionMethod || '';
+
+ // 계약종류
+ const contractType = basicInfo.type || '';
+
+ // 업체선정방식
+ const vendorSelectionMethod = basicInfo.contractSourceType || '';
+
+ // 매입 부가가치세
+ const taxType = basicInfo.taxType || '';
+
+ // SHI 지급조건
+ const paymentTerm = basicInfo.paymentTerm || '';
+
+ // SHI 인도조건
+ const deliveryTerm = basicInfo.deliveryTerm || '';
+ const deliveryType = basicInfo.deliveryType || '';
+
+ // 사외업체 야드 투입 여부
+ const externalYardEntry = basicInfo.externalYardEntry === 'Y' ? '예' : '아니오';
+
+ // 직종
+ const workType = basicInfo.workType || '';
+
+ // 재하도 협력사
+ const subcontractVendor = basicInfo.subcontractVendorName || '';
+
+ // 계약 내용
+ const contractContent = basicInfo.notes || basicInfo.name || '';
+
+ // 계약성립조건
+ let establishmentConditionsText = '';
+ if (basicInfo.contractEstablishmentConditions) {
+ try {
+ const cond = typeof basicInfo.contractEstablishmentConditions === 'string'
+ ? JSON.parse(basicInfo.contractEstablishmentConditions)
+ : basicInfo.contractEstablishmentConditions;
+
+ const active: string[] = [];
+ if (cond.regularVendorRegistration) active.push('정규업체 등록(실사 포함) 시');
+ if (cond.projectAward) active.push('프로젝트 수주 시');
+ if (cond.ownerApproval) active.push('선주 승인 시');
+ if (cond.other) active.push('기타');
+ establishmentConditionsText = active.join(', ');
+ } catch (e) {
+ console.warn('계약성립조건 파싱 실패:', e);
+ }
+ }
+
+ // 계약해지조건
+ let terminationConditionsText = '';
+ if (basicInfo.contractTerminationConditions) {
+ try {
+ const cond = typeof basicInfo.contractTerminationConditions === 'string'
+ ? JSON.parse(basicInfo.contractTerminationConditions)
+ : basicInfo.contractTerminationConditions;
+
+ const active: string[] = [];
+ if (cond.standardTermination) active.push('표준 계약해지조건');
+ if (cond.projectNotAwarded) active.push('프로젝트 미수주 시');
+ if (cond.other) active.push('기타');
+ terminationConditionsText = active.join(', ');
+ } catch (e) {
+ console.warn('계약해지조건 파싱 실패:', e);
+ }
+ }
+
+ // 협력사 정보
+ const vendorCode = basicInfo.vendorCode || '';
+ const vendorName = basicInfo.vendorName || '';
+ const vendorContactPerson = basicInfo.vendorContactPerson || '';
+ const vendorPhone = basicInfo.vendorPhone || '';
+ const vendorEmail = basicInfo.vendorEmail || '';
+ const vendorNote = '';
+
+ // 자재 정보 (최대 100건)
+ const materialItems = items.slice(0, 100);
+ const materialCount = items.length;
+
+ // 보증 정보
+ const guarantees: Array<{
+ type: string;
+ order: number;
+ bondNumber: string;
+ rate: string;
+ amount: string;
+ period: string;
+ startDate: string;
+ endDate: string;
+ issuer: string;
+ }> = [];
+
+ // // 계약보증 (첫 번째 항목만 사용)
+ // if (basicInfo.contractBond) {
+ // const bond = typeof basicInfo.contractBond === 'string'
+ // ? JSON.parse(basicInfo.contractBond)
+ // : basicInfo.contractBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '계약보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 지급보증 (첫 번째 항목만 사용)
+ // if (basicInfo.paymentBond) {
+ // const bond = typeof basicInfo.paymentBond === 'string'
+ // ? JSON.parse(basicInfo.paymentBond)
+ // : basicInfo.paymentBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '지급보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 하자보증 (첫 번째 항목만 사용)
+ // if (basicInfo.defectBond) {
+ // const bond = typeof basicInfo.defectBond === 'string'
+ // ? JSON.parse(basicInfo.defectBond)
+ // : basicInfo.defectBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '하자보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 보증 전체 비고
+ // const guaranteeNote = basicInfo.guaranteeNote || '';
+
+
+ // 총 계약 금액 계산
+ const totalContractAmount = items.reduce((sum, item) => {
+ const amount = Number(item.contractAmount || item.totalLineAmount || 0);
+ return sum + (isNaN(amount) ? 0 : amount);
+ }, 0);
+
+ // 변수 매핑
+ const variables: Record<string, string> = {
+ // 계약 기본 정보
+ '계약번호': String(basicInfo.contractNumber || ''),
+ '계약명': String(basicInfo.name || basicInfo.contractName || ''),
+ '계약체결방식': String(contractExecutionMethod),
+ '계약종류': String(contractType),
+ '구매담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '업체선정방식': String(vendorSelectionMethod),
+ '입찰번호': String(basicInfo.linkedBidNumber || ''),
+ '입찰명': String(basicInfo.linkedBidName || ''),
+ '계약기간': contractPeriod,
+ '계약일자': formatDate(basicInfo.registeredAt || basicInfo.createdAt),
+ '매입_부가가치세': String(taxType),
+ '계약_담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '계약부서': String(basicInfo.departmentName || ''),
+ '계약금액': formatCurrency(basicInfo.contractAmount),
+ 'SHI_지급조건': String(paymentTerm),
+ 'SHI_인도조건': String(deliveryTerm),
+ 'SHI_인도조건_옵션': String(deliveryType),
+ '선적지': String(basicInfo.shippingLocation || ''),
+ '하역지': String(basicInfo.dischargeLocation || ''),
+ '사외업체_야드_투입여부': externalYardEntry,
+ '프로젝트': String(basicInfo.projectName || basicInfo.projectCode || ''),
+ '직종': String(workType),
+ '재하도_협력사': String(subcontractVendor),
+ '계약내용': String(contractContent),
+ '계약성립조건': establishmentConditionsText,
+ '계약해지조건': terminationConditionsText,
+
+ // 협력사 정보
+ '협력사코드': String(vendorCode),
+ '협력사명': String(vendorName),
+ '협력사_담당자': String(vendorContactPerson),
+ '전화번호': String(vendorPhone),
+ '이메일': String(vendorEmail),
+ '비고': String(vendorNote),
+
+ // 자재 정보
+ '대상_자재_수': String(materialCount),
+ };
+
+ // 자재 정보 변수 (최대 100건)
+ materialItems.forEach((item, index) => {
+ const idx = index + 1;
+ variables[`플랜트_${idx}`] = String(item.plant || '');
+ variables[`프로젝트_${idx}`] = String(item.projectName || item.projectCode || '');
+ variables[`자재그룹_${idx}`] = String(item.itemGroup || item.itemCode || '');
+ variables[`자재그룹명_${idx}`] = String(item.itemGroupName || '');
+ variables[`자재번호_${idx}`] = String(item.itemCode || '');
+ variables[`자재상세_${idx}`] = String(item.itemInfo || item.description || '');
+ variables[`연간단가여부_${idx}`] = String(item.isAnnualPrice ? '예' : '아니오');
+ variables[`수량_${idx}`] = formatCurrency(item.quantity);
+ variables[`구매단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`계약단가_${idx}`] = formatCurrency(item.contractUnitPrice || item.unitPrice);
+ variables[`수량단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`총중량_${idx}`] = formatCurrency(item.totalWeight);
+ variables[`중량단위_${idx}`] = String(item.weightUnit || '');
+ variables[`계약금액_${idx}`] = formatCurrency(item.contractAmount || item.totalLineAmount);
+ });
+
+ // 총 계약 금액
+ variables['총_계약금액'] = formatCurrency(totalContractAmount);
+
+ // // 보증 정보 변수 (첫 번째 항목만 사용)
+ // const contractGuarantee = guarantees.find(g => g.type === '계약보증');
+ // if (contractGuarantee) {
+ // variables['계약보증_차수_1'] = String(contractGuarantee.order);
+ // variables['계약보증_증권번호_1'] = String(contractGuarantee.bondNumber || '');
+ // variables['계약보증_보증금율_1'] = String(contractGuarantee.rate || '');
+ // variables['계약보증_보증금액_1'] = String(contractGuarantee.amount || '');
+ // variables['계약보증_보증기간_1'] = String(contractGuarantee.period || '');
+ // variables['계약보증_시작일_1'] = String(contractGuarantee.startDate || '');
+ // variables['계약보증_종료일_1'] = String(contractGuarantee.endDate || '');
+ // variables['계약보증_발행기관_1'] = String(contractGuarantee.issuer || '');
+ // variables['계약보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 계약보증 비고로 사용
+ // }
+
+ // const paymentGuarantee = guarantees.find(g => g.type === '지급보증');
+ // if (paymentGuarantee) {
+ // variables['지급보증_차수_1'] = String(paymentGuarantee.order);
+ // variables['지급보증_증권번호_1'] = String(paymentGuarantee.bondNumber || '');
+ // variables['지급보증_보증금율_1'] = String(paymentGuarantee.rate || '');
+ // variables['지급보증_보증금액_1'] = String(paymentGuarantee.amount || '');
+ // variables['지급보증_보증기간_1'] = String(paymentGuarantee.period || '');
+ // variables['지급보증_시작일_1'] = String(paymentGuarantee.startDate || '');
+ // variables['지급보증_종료일_1'] = String(paymentGuarantee.endDate || '');
+ // variables['지급보증_발행기관_1'] = String(paymentGuarantee.issuer || '');
+ // variables['지급보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 지급보증 비고로 사용
+ // }
+
+ // const defectGuarantee = guarantees.find(g => g.type === '하자보증');
+ // if (defectGuarantee) {
+ // variables['하자보증_차수_1'] = String(defectGuarantee.order);
+ // variables['하자보증_증권번호_1'] = String(defectGuarantee.bondNumber || '');
+ // variables['하자보증_보증금율_1'] = String(defectGuarantee.rate || '');
+ // variables['하자보증_보증금액_1'] = String(defectGuarantee.amount || '');
+ // variables['하자보증_보증기간_1'] = String(defectGuarantee.period || '');
+ // variables['하자보증_시작일_1'] = String(defectGuarantee.startDate || '');
+ // variables['하자보증_종료일_1'] = String(defectGuarantee.endDate || '');
+ // variables['하자보증_발행기관_1'] = String(defectGuarantee.issuer || '');
+ // variables['하자보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 하자보증 비고로 사용
+ // }
+
+ // 하도급 체크리스트 변수 (새로운 템플릿 구조에 맞춤)
+ if (subcontractChecklist) {
+ variables['작업전_서면발급_체크'] = String(subcontractChecklist.workDocumentIssuedCheck || subcontractChecklist.workDocumentIssued || '');
+ variables['기재사항_1'] = String(subcontractChecklist.legalItem1 || subcontractChecklist.sixLegalItems1 || '');
+ variables['기재사항_2'] = String(subcontractChecklist.legalItem2 || subcontractChecklist.sixLegalItems2 || '');
+ variables['기재사항_3'] = String(subcontractChecklist.legalItem3 || subcontractChecklist.sixLegalItems3 || '');
+ variables['기재사항_4'] = String(subcontractChecklist.legalItem4 || subcontractChecklist.sixLegalItems4 || '');
+ variables['기재사항_5'] = String(subcontractChecklist.legalItem5 || subcontractChecklist.sixLegalItems5 || '');
+ variables['기재사항_6'] = String(subcontractChecklist.legalItem6 || subcontractChecklist.sixLegalItems6 || '');
+ variables['부당대금_결정'] = String(subcontractChecklist.unfairPriceDecision || subcontractChecklist.unfairSubcontractPrice || '');
+ variables['점검결과'] = String(subcontractChecklist.inspectionResult || subcontractChecklist.overallResult || '');
+ variables['귀책부서'] = String(subcontractChecklist.responsibleDepartment || subcontractChecklist.overallDepartment || '');
+ variables['원인'] = String(subcontractChecklist.cause || subcontractChecklist.overallCause || '');
+ variables['대책'] = String(subcontractChecklist.countermeasure || subcontractChecklist.overallMeasure || '');
+ }
+
+ return variables;
+}
+
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
index 46251c71..db0901cb 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -1,1068 +1,1200 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { toast } from 'sonner'
-import {
- FileText,
- Upload,
- Eye,
- Send,
- CheckCircle,
- Download,
- AlertCircle
-} from 'lucide-react'
-import { ContractDocuments } from './general-contract-documents'
-import { getActiveContractTemplates } from '@/lib/bidding/service'
-import { type BasicContractTemplate } from '@/db/schema'
-import {
- getBasicInfo,
- getContractItems,
- getSubcontractChecklist,
- uploadContractApprovalFile,
- sendContractApprovalRequest,
- getContractById,
- getContractTemplateByContractType,
- getStorageInfo
-} from '../service'
-import { mapContractDataToTemplateVariables } from '../utils'
-
-interface ContractApprovalRequestDialogProps {
- contract: Record<string, unknown>
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-interface ContractSummary {
- basicInfo: Record<string, unknown>
- items: Record<string, unknown>[]
- subcontractChecklist: Record<string, unknown> | null
- storageInfo?: Record<string, unknown>[]
-}
-
-export function ContractApprovalRequestDialog({
- contract,
- open,
- onOpenChange
-}: ContractApprovalRequestDialogProps) {
- const { data: session } = useSession()
- const [currentStep, setCurrentStep] = useState(1)
- const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
- const [uploadedFile, setUploadedFile] = useState<File | null>(null)
- const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
- const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
- const [isLoading, setIsLoading] = useState(false)
- const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
- const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
-
- // 기본계약 관련 상태
- const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }>>([])
- const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
-
- const contractId = contract.id as number
- const userId = session?.user?.id || ''
-
-
- // 기본계약 생성 함수 (최종 전송 시점에 호출)
- const generateBasicContractPdf = async (
- vendorId: number,
- contractType: string,
- templateName: string
- ): Promise<{ buffer: number[], fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 액션 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- const errorText = await prepareResponse.text();
- throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
- }
-
- const { template, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- enableOfficeEditing: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
-
- instance.UI.dispose();
- return {
- buffer: Array.from(pdfBuffer),
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
-
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
- throw error;
- }
- };
-
- // 기본계약 생성 및 선택 초기화
- const initializeBasicContracts = React.useCallback(async () => {
- if (!contractSummary?.basicInfo) return;
-
- setIsLoadingBasicContracts(true);
- try {
- // 기본적으로 사용할 수 있는 계약서 타입들
- const availableContracts: Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }> = [
- { type: "NDA", templateName: "비밀", checked: false },
- { type: "General_GTC", templateName: "General GTC", checked: false },
- { type: "기술자료", templateName: "기술", checked: false }
- ];
-
- // 프로젝트 코드가 있으면 Project GTC도 추가
- if (contractSummary.basicInfo.projectCode) {
- availableContracts.push({
- type: "Project_GTC",
- templateName: contractSummary.basicInfo.projectCode as string,
- checked: false
- });
- }
-
- setSelectedBasicContracts(availableContracts);
- } catch (error) {
- console.error('기본계약 초기화 실패:', error);
- toast.error('기본계약 초기화에 실패했습니다.');
- } finally {
- setIsLoadingBasicContracts(false);
- }
- }, [contractSummary]);
-
- // 기본계약 선택 토글
- const toggleBasicContract = (type: string) => {
- setSelectedBasicContracts(prev =>
- prev.map(contract =>
- contract.type === type
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- );
- };
-
-
- // 1단계: 계약 현황 수집
- const collectContractSummary = React.useCallback(async () => {
- setIsLoading(true)
- try {
- // 각 컴포넌트에서 활성화된 데이터만 수집
- const summary: ContractSummary = {
- basicInfo: {},
- items: [],
- subcontractChecklist: null
- }
-
- // Basic Info 확인 (항상 활성화)
- try {
- const basicInfoData = await getBasicInfo(contractId)
- if (basicInfoData && basicInfoData.success) {
- summary.basicInfo = basicInfoData.data || {}
- }
- // externalYardEntry 정보도 추가로 가져오기
- const contractData = await getContractById(contractId)
- if (contractData) {
- summary.basicInfo = {
- ...summary.basicInfo,
- externalYardEntry: contractData.externalYardEntry || 'N'
- }
- }
- } catch {
- console.log('Basic Info 데이터 없음')
- }
-
- // 품목 정보 확인
- try {
- const itemsData = await getContractItems(contractId)
- if (itemsData && itemsData.length > 0) {
- summary.items = itemsData
- }
- } catch {
- console.log('품목 정보 데이터 없음')
- }
-
- try {
- // Subcontract Checklist 확인
- const subcontractData = await getSubcontractChecklist(contractId)
- if (subcontractData && subcontractData.success && subcontractData.enabled) {
- summary.subcontractChecklist = subcontractData.data
- }
- } catch {
- console.log('Subcontract Checklist 데이터 없음')
- }
-
- // 임치(물품보관) 계약 정보 확인 (SG)
- try {
- if (summary.basicInfo?.contractType === 'SG') {
- const storageData = await getStorageInfo(contractId)
- if (storageData && storageData.length > 0) {
- summary.storageInfo = storageData
- }
- }
- } catch {
- console.log('임치계약 정보 없음')
- }
-
- console.log('contractSummary 구조:', summary)
- console.log('basicInfo 내용:', summary.basicInfo)
- setContractSummary(summary)
- } catch (error) {
- console.error('Error collecting contract summary:', error)
- toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }, [contractId])
-
- // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
- const generatePdf = async () => {
- if (!contractSummary) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 1. 계약 유형에 맞는 템플릿 조회
- const contractType = contractSummary.basicInfo.contractType as string
- const templateResult = await getContractTemplateByContractType(contractType)
-
- if (!templateResult.success || !templateResult.template) {
- throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
- }
-
- const template = templateResult.template
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- })
-
- if (!templateResponse.ok) {
- throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
- }
-
- const templateBlob = await templateResponse.blob()
- const templateFile = new File([templateBlob], template.fileName || "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- })
-
- // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- })
-
- // 템플릿 변수 매핑
- const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
-
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
-
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
-
- console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
-
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error: any) {
- console.error('❌ PDF 생성 실패:', error)
- const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
- toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 미리보기 기능
- const openPdfPreview = async () => {
- if (!generatedPdfBuffer) {
- toast.error('생성된 PDF가 없습니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 기존 인스턴스가 있다면 정리
- if (pdfViewerInstance) {
- console.log("🔄 기존 WebViewer 인스턴스 정리")
- try {
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('기존 WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 미리보기용 컨테이너 확인
- let previewDiv = document.getElementById('pdf-preview-container')
- if (!previewDiv) {
- console.log("🔄 컨테이너 생성")
- previewDiv = document.createElement('div')
- previewDiv.id = 'pdf-preview-container'
- previewDiv.className = 'w-full h-full'
- previewDiv.style.width = '100%'
- previewDiv.style.height = '100%'
-
- // 실제 컨테이너에 추가
- const actualContainer = document.querySelector('[data-pdf-container]')
- if (actualContainer) {
- actualContainer.appendChild(previewDiv)
- }
- }
-
- console.log("🔄 WebViewer 인스턴스 생성 시작")
-
- // WebViewer 인스턴스 생성 (문서 없이)
- const instance = await Promise.race([
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- previewDiv
- ),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
- )
- ])
-
- console.log("🔄 WebViewer 인스턴스 생성 완료")
- setPdfViewerInstance(instance)
-
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- console.log("🔄 PDF Blob URL 생성:", pdfUrl)
-
- // 문서 로드
- console.log("🔄 문서 로드 시작")
- const { documentViewer } = (instance as any).Core
-
- // 문서 로드 이벤트 대기
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('문서 로드 타임아웃'))
- }, 20000)
-
- const onDocumentLoaded = () => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.log("🔄 문서 로드 완료")
- resolve(true)
- }
-
- const onDocumentError = (error: any) => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.error('문서 로드 오류:', error)
- reject(error)
- }
-
- documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.addEventListener('documentError', onDocumentError)
-
- // 문서 로드 시작
- documentViewer.loadDocument(pdfUrl)
- })
-
- setIsPdfPreviewVisible(true)
- toast.success('PDF 미리보기가 준비되었습니다.')
-
- } catch (error) {
- console.error('PDF 미리보기 실패:', error)
- toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 다운로드 기능
- const downloadPdf = () => {
- if (!generatedPdfBuffer) {
- toast.error('다운로드할 PDF가 없습니다.')
- return
- }
-
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
-
- const link = document.createElement('a')
- link.href = pdfUrl
- link.download = `contract_${contractId}_${Date.now()}.pdf`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
-
- URL.revokeObjectURL(pdfUrl)
- toast.success('PDF가 다운로드되었습니다.')
- }
-
- // PDF 미리보기 닫기
- const closePdfPreview = () => {
- console.log("🔄 PDF 미리보기 닫기 시작")
- if (pdfViewerInstance) {
- try {
- console.log("🔄 WebViewer 인스턴스 정리")
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 컨테이너 정리
- const previewDiv = document.getElementById('pdf-preview-container')
- if (previewDiv) {
- try {
- previewDiv.innerHTML = ''
- } catch (error) {
- console.warn('컨테이너 정리 중 오류:', error)
- }
- }
-
- setIsPdfPreviewVisible(false)
- console.log("🔄 PDF 미리보기 닫기 완료")
- }
-
- // 최종 전송
- const handleFinalSubmit = async () => {
- if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
- toast.error('생성된 PDF가 필요합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 기본계약서 생성 (최종 전송 시점에)
- let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
-
- const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
- if (contractsToGenerate.length > 0) {
- // vendorId 조회
- let vendorId: number | undefined;
- try {
- const basicInfoData = await getBasicInfo(contractId);
- if (basicInfoData && basicInfoData.success && basicInfoData.data) {
- vendorId = basicInfoData.data.vendorId;
- }
- } catch (error) {
- console.error('vendorId 조회 실패:', error);
- }
-
- if (vendorId) {
- toast.info('기본계약서를 생성하는 중입니다...');
-
- for (const contract of contractsToGenerate) {
- try {
- const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
- generatedBasicContractPdfs.push({
- key: `${vendorId}_${contract.type}_${contract.templateName}`,
- ...pdf
- });
- } catch (error) {
- console.error(`${contract.type} 계약서 생성 실패:`, error);
- // 개별 실패는 전체를 중단하지 않음
- }
- }
-
- if (generatedBasicContractPdfs.length > 0) {
- toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
- }
- }
- }
-
- // 서버액션을 사용하여 계약승인요청 전송
- const result = await sendContractApprovalRequest(
- contractSummary,
- generatedPdfBuffer,
- 'contractDocument',
- userId,
- generatedBasicContractPdfs
- )
-
- if (result.success) {
- toast.success('계약승인요청이 전송되었습니다.')
- onOpenChange(false)
- } else {
- // 서버에서 이미 처리된 에러 메시지 표시
- toast.error(result.error || '계약승인요청 전송 실패')
- return
- }
- } catch (error: any) {
- console.error('Error submitting approval request:', error)
-
- // 데이터베이스 중복 키 오류 처리
- if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
- toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.')
- return
- }
-
- // 다른 오류에 대한 일반적인 처리
- toast.error('계약승인요청 전송 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 다이얼로그가 열릴 때 1단계 데이터 수집
- useEffect(() => {
- if (open && currentStep === 1) {
- collectContractSummary()
- }
- }, [open, currentStep, collectContractSummary])
-
- // 계약 요약이 준비되면 기본계약 초기화
- useEffect(() => {
- if (contractSummary && currentStep === 2) {
- const loadBasicContracts = async () => {
- await initializeBasicContracts()
- }
- loadBasicContracts()
- }
- }, [contractSummary, currentStep, initializeBasicContracts])
-
- // 다이얼로그가 닫힐 때 PDF 뷰어 정리
- useEffect(() => {
- if (!open) {
- closePdfPreview()
- }
- }, [open])
-
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 계약승인요청
- </DialogTitle>
- </DialogHeader>
-
- <Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-3">
- <TabsTrigger value="1" disabled={currentStep < 1}>
- 1. 계약 현황 정리
- </TabsTrigger>
- <TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 기본계약 체크
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
- </TabsTrigger>
- </TabsList>
-
- {/* 1단계: 계약 현황 정리 */}
- <TabsContent value="1" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <CheckCircle className="h-5 w-5 text-green-600" />
- 작성된 계약 현황
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoading ? (
- <div className="text-center py-4">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 기본 정보 (필수) */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">기본 정보</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
- </div>
- <div>
- <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
- </div>
- <div>
- <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
- </div>
- <div>
- <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
- </div>
- <div>
- <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
- </div>
- <div>
- <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
- </div>
- <div>
- <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
- </div>
- <div>
- <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
- </div>
- <div>
- <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
- </div>
- <div>
- <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
- </div>
- <div>
- <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
- </div>
- <div>
- <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
- </div>
- </div>
- </div>
-
- {/* 지급/인도 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">지급/인도 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
- </div>
- <div>
- <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
- </div>
- <div>
- <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
- </div>
- <div>
- <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
- </div>
- <div>
- <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
- </div>
- <div>
- <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
- </div>
- <div>
- <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
- </div>
- <div>
- <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
- </div>
- </div>
- </div>
-
- {/* 추가 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">추가 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
- </div>
- <div>
- <span className="font-medium">계약성립조건:</span>
- {contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- <div>
- <span className="font-medium">계약해지조건:</span>
- {contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- </div>
- </div>
-
- {/* 품목 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="items-enabled"
- checked={contractSummary?.items && contractSummary.items.length > 0}
- disabled
- />
- <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
- <Badge variant="outline">선택</Badge>
- </div>
- {contractSummary?.items && contractSummary.items.length > 0 ? (
- <div className="space-y-2">
- <p className="text-sm text-muted-foreground">
- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
- </p>
- <div className="max-h-32 overflow-y-auto">
- {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
- <div key={index} className="text-xs bg-gray-50 p-2 rounded">
- <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
- <div className="text-muted-foreground">
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
- </div>
- </div>
- ))}
- {contractSummary.items.length > 3 && (
- <div className="text-xs text-muted-foreground text-center">
- ... 외 {contractSummary.items.length - 3}개 품목
- </div>
- )}
- </div>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- 품목 정보가 입력되지 않았습니다.
- </p>
- )}
- </div>
-
- {/* 하도급 체크리스트 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="subcontract-enabled"
- checked={!!contractSummary?.subcontractChecklist}
- disabled
- />
- <Label htmlFor="subcontract-enabled" className="font-medium">
- 하도급 체크리스트
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.subcontractChecklist
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-end">
- <Button
- onClick={() => setCurrentStep(2)}
- disabled={isLoading}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 2단계: 기본계약 체크 */}
- <TabsContent value="2" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택
- </CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
- </p>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingBasicContracts ? (
- <div className="text-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedBasicContracts.length > 0 ? (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-medium">필요한 기본계약서</h4>
- <Badge variant="outline">
- {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
- </Badge>
- </div>
-
- <div className="grid gap-3">
- {selectedBasicContracts.map((contract) => (
- <div
- key={contract.type}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.type}`}
- checked={contract.checked}
- onCheckedChange={() => toggleBasicContract(contract.type)}
- />
- <div>
- <Label
- htmlFor={`contract-${contract.type}`}
- className="font-medium cursor-pointer"
- >
- {contract.type}
- </Label>
- <p className="text-sm text-muted-foreground">
- 템플릿: {contract.templateName}
- </p>
- </div>
- </div>
- <Badge
- variant="secondary"
- className="text-xs"
- >
- {contract.checked ? "선택됨" : "미선택"}
- </Badge>
- </div>
- ))}
- </div>
-
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>기본계약서 목록을 불러올 수 없습니다.</p>
- <p className="text-sm">잠시 후 다시 시도해주세요.</p>
- </div>
- )}
-
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={isLoadingBasicContracts}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 3단계: PDF 미리보기 */}
- <TabsContent value="3" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Eye className="h-5 w-5 text-purple-600" />
- PDF 미리보기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {!generatedPdfUrl ? (
- <div className="text-center py-8">
- <Button onClick={generatePdf} disabled={isLoading}>
- {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
- </Button>
- </div>
- ) : (
- <div className="space-y-4">
- <div className="border rounded-lg p-4 bg-green-50">
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900">PDF 생성 완료</span>
- </div>
- </div>
-
- <div className="border rounded-lg p-4">
- <div className="flex items-center justify-between mb-4">
- <h4 className="font-medium">생성된 PDF</h4>
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={downloadPdf}
- disabled={isLoading}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={openPdfPreview}
- disabled={isLoading}
- >
- <Eye className="h-4 w-4 mr-2" />
- 미리보기
- </Button>
- </div>
- </div>
-
- {/* PDF 미리보기 영역 */}
- <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
- {isPdfPreviewVisible ? (
- <>
- <div className="absolute top-2 right-2 z-10">
- <Button
- variant="outline"
- size="sm"
- onClick={closePdfPreview}
- className="bg-white/90 hover:bg-white"
- >
- ✕ 닫기
- </Button>
- </div>
- <div id="pdf-preview-container" className="w-full h-full" />
- </>
- ) : (
- <div className="flex items-center justify-center h-full">
- <div className="text-center text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-2" />
- <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
- 이전 단계
- </Button>
- <Button
- onClick={handleFinalSubmit}
- disabled={!generatedPdfUrl || isLoading}
- className="bg-green-600 hover:bg-green-700"
- >
- <Send className="h-4 w-4 mr-2" />
- {isLoading ? '전송 중...' : '최종 전송'}
- </Button>
- </div>
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+ AlertCircle
+} from 'lucide-react'
+import { ContractDocuments } from './general-contract-documents'
+import { getActiveContractTemplates } from '@/lib/bidding/service'
+import { type BasicContractTemplate } from '@/db/schema'
+import {
+ getBasicInfo,
+ getContractItems,
+ getSubcontractChecklist,
+ uploadContractApprovalFile,
+ sendContractApprovalRequest,
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
+} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
+import { ApprovalPreviewDialog } from '@/lib/approval/client'
+import { requestContractApprovalWithApproval } from '../approval-actions'
+import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
+
+interface ContractApprovalRequestDialogProps {
+ contract: Record<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
+ pdfPath?: string
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
+}
+
+export function ContractApprovalRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractApprovalRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
+ const [uploadedFile, setUploadedFile] = useState<File | null>(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ // 기본계약 관련 상태
+ const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }>>([])
+ const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
+
+ // 결재 관련 상태
+ const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
+ const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
+ const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null)
+ const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([])
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+
+ // 기본계약 생성 함수 (최종 전송 시점에 호출)
+ const generateBasicContractPdf = async (
+ vendorId: number,
+ contractType: string,
+ templateName: string
+ ): Promise<{ buffer: number[], fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 액션 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ const errorText = await prepareResponse.text();
+ throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
+ }
+
+ const { template, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
+
+ instance.UI.dispose();
+ return {
+ buffer: Array.from(pdfBuffer),
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
+ throw error;
+ }
+ };
+
+ // 기본계약 생성 및 선택 초기화
+ const initializeBasicContracts = React.useCallback(async () => {
+ if (!contractSummary?.basicInfo) return;
+
+ setIsLoadingBasicContracts(true);
+ try {
+ // 기본적으로 사용할 수 있는 계약서 타입들
+ const availableContracts: Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }> = [
+ { type: "NDA", templateName: "비밀", checked: false },
+ { type: "General_GTC", templateName: "General GTC", checked: false },
+ { type: "기술자료", templateName: "기술", checked: false }
+ ];
+
+ // 프로젝트 코드가 있으면 Project GTC도 추가
+ if (contractSummary.basicInfo.projectCode) {
+ availableContracts.push({
+ type: "Project_GTC",
+ templateName: contractSummary.basicInfo.projectCode as string,
+ checked: false
+ });
+ }
+
+ setSelectedBasicContracts(availableContracts);
+ } catch (error) {
+ console.error('기본계약 초기화 실패:', error);
+ toast.error('기본계약 초기화에 실패했습니다.');
+ } finally {
+ setIsLoadingBasicContracts(false);
+ }
+ }, [contractSummary]);
+
+ // 기본계약 선택 토글
+ const toggleBasicContract = (type: string) => {
+ setSelectedBasicContracts(prev =>
+ prev.map(contract =>
+ contract.type === type
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ );
+ };
+
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } catch {
+ console.log('Subcontract Checklist 데이터 없음')
+ }
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
+
+ console.log('contractSummary 구조:', summary)
+ console.log('basicInfo 내용:', summary.basicInfo)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
+
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
+ }
+
+ const template = templateResult.template
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
+
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error: any) {
+ console.error('❌ PDF 생성 실패:', error)
+ const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ console.log("🔄 PDF Blob URL 생성:", pdfUrl)
+
+ // 문서 로드
+ console.log("🔄 문서 로드 시작")
+ const { documentViewer } = (instance as any).Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: any) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // PDF를 서버에 저장하는 함수 (API route 사용)
+ const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => {
+ try {
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
+
+ // FormData 생성
+ const formData = new FormData();
+ formData.append('file', pdfBlob, fileName);
+ formData.append('contractId', String(contractId));
+
+ // API route로 업로드
+ const response = await fetch('/api/general-contracts/upload-pdf', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ return result.filePath;
+ } catch (error) {
+ console.error('PDF 저장 실패:', error);
+ return null;
+ }
+ };
+
+ // 최종 전송 - 결재 프로세스 시작
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 기본계약서 생성 (최종 전송 시점에)
+ let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
+
+ const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
+ if (contractsToGenerate.length > 0) {
+ // vendorId 조회
+ let vendorId: number | undefined;
+ try {
+ const basicInfoData = await getBasicInfo(contractId);
+ if (basicInfoData && basicInfoData.success && basicInfoData.data) {
+ vendorId = basicInfoData.data.vendorId;
+ }
+ } catch (error) {
+ console.error('vendorId 조회 실패:', error);
+ }
+
+ if (vendorId) {
+ toast.info('기본계약서를 생성하는 중입니다...');
+
+ for (const contract of contractsToGenerate) {
+ try {
+ const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
+ generatedBasicContractPdfs.push({
+ key: `${vendorId}_${contract.type}_${contract.templateName}`,
+ ...pdf
+ });
+ } catch (error) {
+ console.error(`${contract.type} 계약서 생성 실패:`, error);
+ // 개별 실패는 전체를 중단하지 않음
+ }
+ }
+
+ if (generatedBasicContractPdfs.length > 0) {
+ toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
+ }
+ }
+ }
+
+ // PDF를 서버에 저장
+ toast.info('PDF를 서버에 저장하는 중입니다...');
+ const pdfPath = await savePdfToServer(
+ generatedPdfBuffer,
+ `contract_${contractId}_${Date.now()}.pdf`
+ );
+
+ if (!pdfPath) {
+ toast.error('PDF 저장에 실패했습니다.');
+ return;
+ }
+
+ setSavedPdfPath(pdfPath);
+ setSavedBasicContractPdfs(generatedBasicContractPdfs);
+
+ // 결재 템플릿 변수 매핑
+ const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
+ setApprovalVariables(approvalVars);
+
+ // 계약승인요청 dialog close
+ onOpenChange(false);
+
+ // 결재 템플릿 dialog open
+ setApprovalDialogOpen(true);
+ } catch (error: any) {
+ console.error('Error preparing approval:', error);
+ toast.error('결재 준비 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 결재 등록 처리
+ const handleApprovalSubmit = async (data: {
+ approvers: string[];
+ title: string;
+ attachments?: File[];
+ }) => {
+ if (!contractSummary || !savedPdfPath) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await requestContractApprovalWithApproval({
+ contractId,
+ contractSummary: {
+ ...contractSummary,
+ // PDF 경로를 contractSummary에 추가
+ pdfPath: savedPdfPath || undefined,
+ basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
+ } as ContractSummary,
+ currentUser: {
+ id: Number(userId),
+ epId: session?.user?.epId || null,
+ email: session?.user?.email || undefined,
+ },
+ approvers: data.approvers,
+ title: data.title,
+ });
+
+ if (result.status === 'pending_approval') {
+ toast.success('결재가 등록되었습니다.')
+ setApprovalDialogOpen(false);
+ } else {
+ toast.error('결재 등록에 실패했습니다.')
+ }
+ } catch (error: any) {
+ console.error('Error submitting approval:', error);
+ toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 계약 요약이 준비되면 기본계약 초기화
+ useEffect(() => {
+ if (contractSummary && currentStep === 2) {
+ const loadBasicContracts = async () => {
+ await initializeBasicContracts()
+ }
+ loadBasicContracts()
+ }
+ }, [contractSummary, currentStep, initializeBasicContracts])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ }
+ }, [open])
+
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 계약승인요청
+ </DialogTitle>
+ </DialogHeader>
+
+ <Tabs value={currentStep.toString()} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 계약 현황 정리
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 기본계약 체크
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. PDF 미리보기
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 1단계: 계약 현황 정리 */}
+ <TabsContent value="1" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5 text-green-600" />
+ 작성된 계약 현황
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 기본 정보 (필수) */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">기본 정보</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
+ </div>
+ <div>
+ <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
+ </div>
+ <div>
+ <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
+ </div>
+ <div>
+ <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+ </div>
+ </div>
+ </div>
+
+ {/* 지급/인도 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">지급/인도 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
+ </div>
+ <div>
+ <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">추가 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약성립조건:</span>
+ {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, boolean>)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record<string, string> = {
+ 'ownerApproval': '정규업체 등록(실사 포함) 시',
+ 'regularVendorRegistration': '프로젝트 수주 시',
+ 'shipOwnerApproval': '선주 승인 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, boolean>)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record<string, string> = {
+ 'standardTermination': '표준 계약해지조건',
+ 'projectNotAwarded': '프로젝트 미수주 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+ </div>
+ </div>
+ </div>
+
+ {/* 품목 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="items-enabled"
+ checked={contractSummary?.items && contractSummary.items.length > 0}
+ disabled
+ />
+ <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+ <div className="space-y-2">
+ <p className="text-sm text-muted-foreground">
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+ </p>
+ <div className="max-h-32 overflow-y-auto">
+ {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
+ <div key={index} className="text-xs bg-gray-50 p-2 rounded">
+ <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
+ <div className="text-muted-foreground">
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
+ </div>
+ </div>
+ ))}
+ {contractSummary.items.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ ... 외 {contractSummary.items.length - 3}개 품목
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ 품목 정보가 입력되지 않았습니다.
+ </p>
+ )}
+ </div>
+
+ {/* 하도급 체크리스트 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="subcontract-enabled"
+ checked={!!contractSummary?.subcontractChecklist}
+ disabled
+ />
+ <Label htmlFor="subcontract-enabled" className="font-medium">
+ 하도급 체크리스트
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={() => setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 2단계: 기본계약 체크 */}
+ <TabsContent value="2" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5 text-blue-600" />
+ 기본계약서 선택
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoadingBasicContracts ? (
+ <div className="text-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedBasicContracts.length > 0 ? (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-medium">필요한 기본계약서</h4>
+ <Badge variant="outline">
+ {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
+ </Badge>
+ </div>
+
+ <div className="grid gap-3">
+ {selectedBasicContracts.map((contract) => (
+ <div
+ key={contract.type}
+ className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ id={`contract-${contract.type}`}
+ checked={contract.checked}
+ onCheckedChange={() => toggleBasicContract(contract.type)}
+ />
+ <div>
+ <Label
+ htmlFor={`contract-${contract.type}`}
+ className="font-medium cursor-pointer"
+ >
+ {contract.type}
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ 템플릿: {contract.templateName}
+ </p>
+ </div>
+ </div>
+ <Badge
+ variant="secondary"
+ className="text-xs"
+ >
+ {contract.checked ? "선택됨" : "미선택"}
+ </Badge>
+ </div>
+ ))}
+ </div>
+
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>기본계약서 목록을 불러올 수 없습니다.</p>
+ <p className="text-sm">잠시 후 다시 시도해주세요.</p>
+ </div>
+ )}
+
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(3)}
+ disabled={isLoadingBasicContracts}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 3단계: PDF 미리보기 */}
+ <TabsContent value="3" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-purple-600" />
+ PDF 미리보기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!generatedPdfUrl ? (
+ <div className="text-center py-8">
+ <Button onClick={generatePdf} disabled={isLoading}>
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+ </Button>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">PDF 생성 완료</span>
+ </div>
+ </div>
+
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-4">
+ <h4 className="font-medium">생성된 PDF</h4>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadPdf}
+ disabled={isLoading}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openPdfPreview}
+ disabled={isLoading}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ </div>
+ </div>
+
+ {/* PDF 미리보기 영역 */}
+ <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
+ {isPdfPreviewVisible ? (
+ <>
+ <div className="absolute top-2 right-2 z-10">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={closePdfPreview}
+ className="bg-white/90 hover:bg-white"
+ >
+ ✕ 닫기
+ </Button>
+ </div>
+ <div id="pdf-preview-container" className="w-full h-full" />
+ </>
+ ) : (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-2" />
+ <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={handleFinalSubmit}
+ disabled={!generatedPdfUrl || isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isLoading ? '전송 중...' : '최종 전송'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+
+ {/* 결재 미리보기 Dialog */}
+ {session?.user && session.user.epId && contractSummary && (
+ <ApprovalPreviewDialog
+ open={approvalDialogOpen}
+ onOpenChange={(open) => {
+ setApprovalDialogOpen(open);
+ if (!open) {
+ setApprovalVariables({});
+ setSavedPdfPath(null);
+ setSavedBasicContractPdfs([]);
+ }
+ }}
+ templateName="일반계약 결재"
+ variables={approvalVariables}
+ title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalSubmit}
+ enableAttachments={false}
+ />
+ )}
+ </Dialog>
)} \ No newline at end of file
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index b0378912..d7533d2e 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -8,7 +8,21 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Save, LoaderIcon, Check, ChevronsUpDown } from 'lucide-react'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { cn } from '@/lib/utils'
import { updateContractBasicInfo, getContractBasicInfo } from '../service'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -140,19 +154,28 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// paymentDelivery에서 퍼센트와 타입 분리
const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ console.log(paymentDeliveryValue,"paymentDeliveryValue")
let paymentDeliveryType = ''
let paymentDeliveryPercentValue = ''
- if (paymentDeliveryValue.includes('%')) {
+ // "60일 이내" 또는 "추가조건"은 그대로 사용
+ if (paymentDeliveryValue === '납품완료일로부터 60일 이내 지급' || paymentDeliveryValue === '추가조건') {
+ paymentDeliveryType = paymentDeliveryValue
+ } else if (paymentDeliveryValue.includes('%')) {
+ // 퍼센트가 포함된 경우 (예: "10% L/C")
const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
if (match) {
paymentDeliveryPercentValue = match[1]
paymentDeliveryType = match[2]
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
}
} else {
+ // 일반 지급조건 코드 (예: "P008")
paymentDeliveryType = paymentDeliveryValue
}
-
+ console.log(paymentDeliveryType,"paymentDeliveryType")
+ console.log(paymentDeliveryPercentValue,"paymentDeliveryPercentValue")
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
// 합의계약(AD, AW)인 경우 인도조건 기본값 설정
@@ -309,6 +332,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
loadShippingPlaces();
loadDestinationPlaces();
}, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+
const handleSaveContractInfo = async () => {
if (!userId) {
toast.error('사용자 정보를 찾을 수 없습니다.')
@@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
return
}
- // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ // paymentDelivery 저장 로직
+ // 1. "60일 이내" 또는 "추가조건"은 그대로 저장
+ // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장
+ // 3. 그 외의 경우는 그대로 저장
+ let paymentDeliveryToSave = formData.paymentDelivery
+
+ if (
+ formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' &&
+ formData.paymentDelivery !== '추가조건' &&
+ (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') &&
+ paymentDeliveryPercent
+ ) {
+ paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ }
+ console.log(paymentDeliveryToSave,"paymentDeliveryToSave")
+
const dataToSave = {
...formData,
- paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
- ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
- : formData.paymentDelivery
+ paymentDelivery: paymentDeliveryToSave,
+ // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined
+ paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건'
+ ? (formData.paymentDeliveryAdditionalText || '')
+ : ''
}
await updateContractBasicInfo(contractId, dataToSave, userId as number)
@@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="paymentDelivery" className="text-xs">지급조건 *</Label>
- <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
- <SelectTrigger className={`h-8 text-xs ${errors.paymentDelivery ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.map((term) => (
- <SelectItem key={term.code} value={term.code} className="text-xs">
- {term.code}
- </SelectItem>
- ))}
- <SelectItem value="납품완료일로부터 60일 이내 지급" className="text-xs">60일 이내</SelectItem>
- <SelectItem value="추가조건" className="text-xs">추가조건</SelectItem>
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.paymentDelivery && "text-muted-foreground",
+ errors.paymentDelivery && "border-red-500"
+ )}
+ >
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.paymentDelivery
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ <CommandItem
+ value="납품완료일로부터 60일 이내 지급"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '납품완료일로부터 60일 이내 지급'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 60일 이내
+ </CommandItem>
+ <CommandItem
+ value="추가조건"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '추가조건'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 추가조건
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
{formData.paymentDelivery === '추가조건' && (
<Input
type="text"
@@ -1152,53 +1273,59 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
</div>
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
<div className="space-y-2">
<Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
- {/* 지불조건 필드 삭제됨
- <div className="space-y-1">
- <Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
- <Select
- value={formData.paymentTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.paymentTerm ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
- <Select
- value={formData.taxType}
- onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.taxType ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code} className="text-xs">
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.taxType && "text-muted-foreground",
+ errors.taxType && "border-red-500"
+ )}
+ >
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === formData.taxType
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
</div>
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 인도조건 */}
<div className="space-y-2">
<Label htmlFor="deliveryTerm" className="text-xs">인도조건</Label>
- <Select
- value={formData.deliveryTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.deliveryTerm && "text-muted-foreground"
+ )}
+ >
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.deliveryTerm
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 선적지 */}
<div className="space-y-2">
<Label htmlFor="shippingLocation" className="text-xs">선적지</Label>
- <Select
- value={formData.shippingLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.shippingLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.shippingLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 하역지 */}
<div className="space-y-2">
<Label htmlFor="dischargeLocation" className="text-xs">하역지</Label>
- <Select
- value={formData.dischargeLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.dischargeLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.dischargeLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 계약납기일 */}
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 15e5c926..be174417 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -30,6 +30,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { ProjectSelector } from '@/components/ProjectSelector'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSearchItem } from '@/lib/material/material-group-service'
+import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
+import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service'
+import { cn } from '@/lib/utils'
interface ContractItem {
id?: number
@@ -41,12 +44,12 @@ interface ContractItem {
materialGroupCode?: string
materialGroupDescription?: string
specification: string
- quantity: number
+ quantity: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
quantityUnit: string
- totalWeight: number
+ totalWeight: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
weightUnit: string
contractDeliveryDate: string
- contractUnitPrice: number
+ contractUnitPrice: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
contractAmount: number
contractCurrency: string
isSelected?: boolean
@@ -103,6 +106,34 @@ export function ContractItemsTable({
contractUnitPrice: ''
})
+ // 천단위 콤마 포맷팅 헬퍼 함수들
+ const formatNumberWithCommas = (value: string | number | null | undefined): string => {
+ if (value === null || value === undefined || value === '') return ''
+ const str = value.toString()
+ const parts = str.split('.')
+ const integerPart = parts[0].replace(/,/g, '')
+
+ // 정수부가 비어있거나 '-' 만 있는 경우 처리
+ if (integerPart === '' || integerPart === '-') {
+ return str
+ }
+
+ const num = parseFloat(integerPart)
+ if (isNaN(num)) return str
+
+ const formattedInt = num.toLocaleString()
+
+ if (parts.length > 1) {
+ return `${formattedInt}.${parts[1]}`
+ }
+
+ return formattedInt
+ }
+
+ const parseNumberFromCommas = (value: string): string => {
+ return value.replace(/,/g, '')
+ }
+
// 초기 데이터 로드
React.useEffect(() => {
const loadItems = async () => {
@@ -123,6 +154,8 @@ export function ContractItemsTable({
}
}
+ // number 타입을 string으로 변환하지 않고 일단 그대로 둠 (렌더링 시 포맷팅)
+ // 단, 입력 중 편의를 위해 string이 들어올 수 있으므로 ContractItem 타입 변경함
return {
id: item.id,
projectId: item.projectId || null,
@@ -172,11 +205,20 @@ export function ContractItemsTable({
// validation 체크
const errors: string[] = []
- for (let index = 0; index < localItems.length; index++) {
- const item = localItems[index]
- if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ // 저장 시 number로 변환된 데이터 준비
+ const itemsToSave = localItems.map(item => ({
+ ...item,
+ quantity: parseFloat(item.quantity.toString().replace(/,/g, '')) || 0,
+ totalWeight: parseFloat(item.totalWeight.toString().replace(/,/g, '')) || 0,
+ contractUnitPrice: parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0,
+ contractAmount: parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0,
+ }));
+
+ for (let index = 0; index < itemsToSave.length; index++) {
+ const item = itemsToSave[index]
+ // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
- if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
+ // if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`)
}
@@ -186,7 +228,7 @@ export function ContractItemsTable({
return
}
- await updateContractItems(contractId, localItems as any)
+ await updateContractItems(contractId, itemsToSave as any)
toast.success('품목정보가 저장되었습니다.')
} catch (error) {
console.error('Error saving contract items:', error)
@@ -197,9 +239,18 @@ export function ContractItemsTable({
}
// 총 금액 계산
- const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0)
- const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0)
- const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0)
+ const totalAmount = localItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0
+ return sum + amount
+ }, 0)
+ const totalQuantity = localItems.reduce((sum, item) => {
+ const quantity = parseFloat(item.quantity.toString().replace(/,/g, '')) || 0
+ return sum + quantity
+ }, 0)
+ const totalUnitPrice = localItems.reduce((sum, item) => {
+ const unitPrice = parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0
+ return sum + unitPrice
+ }, 0)
const amountDifference = availableBudget - totalAmount
const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0
@@ -211,12 +262,14 @@ export function ContractItemsTable({
// 아이템 업데이트
const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => {
const updatedItems = [...localItems]
- updatedItems[index] = { ...updatedItems[index], [field]: value }
+ const updatedItem = { ...updatedItems[index], [field]: value }
+ updatedItems[index] = updatedItem
// 단가나 수량이 변경되면 금액 자동 계산
if (field === 'contractUnitPrice' || field === 'quantity') {
- const item = updatedItems[index]
- updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity
+ const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0
+ const unitPrice = parseFloat(updatedItem.contractUnitPrice.toString().replace(/,/g, '')) || 0
+ updatedItem.contractAmount = unitPrice * quantity
}
setLocalItems(updatedItems)
@@ -271,6 +324,34 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 1회성 품목 선택 시 행 추가
+ const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => {
+ if (!item) return
+
+ const newItem: ContractItem = {
+ projectId: null,
+ itemCode: item.itemCode,
+ itemInfo: item.itemName,
+ materialGroupCode: '',
+ materialGroupDescription: '',
+ specification: item.specification || '',
+ quantity: 0,
+ quantityUnit: item.unit || 'EA',
+ totalWeight: 0,
+ weightUnit: 'KG',
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW',
+ isSelected: false
+ }
+
+ const updatedItems = [...localItems, newItem]
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ toast.success('1회성 품목이 추가되었습니다.')
+ }
+
// 일괄입력 적용
const applyBatchInput = () => {
if (localItems.length === 0) {
@@ -296,7 +377,8 @@ export function ContractItemsTable({
if (batchInputData.contractUnitPrice) {
updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0
// 단가가 변경되면 계약금액도 재계산
- updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity
+ const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0
+ updatedItem.contractAmount = (parseFloat(batchInputData.contractUnitPrice) || 0) * quantity
}
return updatedItem
@@ -382,6 +464,17 @@ export function ContractItemsTable({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel="1회성 품목 추가"
+ triggerVariant="outline"
+ triggerSize="sm"
+ selectedProcurementItem={null}
+ onProcurementItemSelect={handleOneTimeItemSelect}
+ title="1회성 품목 선택"
+ description="추가할 1회성 품목을 선택해주세요."
+ showConfirmButtons={false}
+ disabled={!isEnabled || readOnly}
+ />
<Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
<DialogTrigger asChild>
<Button
@@ -671,14 +764,23 @@ export function ContractItemsTable({
)}
</TableCell> */}
<TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.quantity.toLocaleString()}</span>
+ ) : (
<Input
- type="number"
- value={item.quantity}
- onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.quantity)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'quantity', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
+ )}
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
@@ -707,9 +809,14 @@ export function ContractItemsTable({
<span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span>
) : (
<Input
- type="number"
- value={item.totalWeight}
- onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.totalWeight)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'totalWeight', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
disabled={!isEnabled || isQuantityDisabled}
@@ -756,9 +863,14 @@ export function ContractItemsTable({
<span className="text-sm text-right">{item.contractUnitPrice.toLocaleString()}</span>
) : (
<Input
- type="number"
- value={item.contractUnitPrice}
- onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.contractUnitPrice)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'contractUnitPrice', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
disabled={!isEnabled}
diff --git a/lib/general-contracts/handlers.ts b/lib/general-contracts/handlers.ts
new file mode 100644
index 00000000..029fb9cd
--- /dev/null
+++ b/lib/general-contracts/handlers.ts
@@ -0,0 +1,157 @@
+/**
+ * 일반계약 관련 결재 액션 핸들러
+ *
+ * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리)
+ */
+
+'use server';
+
+import { sendContractApprovalRequest } from './service';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 승인 핸들러 (결재 승인 후 계약승인요청 전송 실행)
+ *
+ * 결재 승인 후 자동으로 계약승인요청을 전송함
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function approveContractInternal(payload: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser?: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ nonsapUserId?: string | null;
+ };
+}) {
+ debugLog('[ContractApprovalHandler] 일반계약 승인 핸들러 시작', {
+ contractId: payload.contractId,
+ contractNumber: payload.contractSummary.basicInfo?.contractNumber,
+ contractName: payload.contractSummary.basicInfo?.name,
+ hasCurrentUser: !!payload.currentUser,
+ });
+
+ try {
+ // 1. 계약 정보 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, payload.contractId))
+ .limit(1);
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.');
+ }
+
+ // 2. 계약승인요청 전송
+ debugLog('[ContractApprovalHandler] sendContractApprovalRequest 호출');
+
+ // PDF 경로에서 PDF 버퍼 읽기
+ const pdfPath = (payload.contractSummary as any).pdfPath;
+ if (!pdfPath) {
+ throw new Error('PDF 경로가 없습니다.');
+ }
+
+ // PDF 파일 읽기
+ const fs = await import('fs/promises');
+ const path = await import('path');
+
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+ const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public");
+
+ // publicPath에서 실제 파일 경로로 변환
+ const actualPath = pdfPath.startsWith('/')
+ ? path.join(baseDir, pdfPath)
+ : path.join(baseDir, 'generalContracts', pdfPath);
+
+ let pdfBuffer: Uint8Array;
+ try {
+ const fileBuffer = await fs.readFile(actualPath);
+ pdfBuffer = new Uint8Array(fileBuffer);
+ } catch (error) {
+ debugError('[ContractApprovalHandler] PDF 파일 읽기 실패', error);
+ throw new Error('PDF 파일을 읽을 수 없습니다.');
+ }
+
+ // 기본계약서는 클라이언트에서 이미 생성되었을 것으로 가정
+ const generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }> =
+ (payload.contractSummary as any).basicContractPdfs || [];
+
+ const userId = payload.currentUser?.id
+ ? String(payload.currentUser.id)
+ : String(contract.registeredById);
+
+ const result = await sendContractApprovalRequest(
+ payload.contractSummary,
+ pdfBuffer,
+ 'contractDocument',
+ userId,
+ generatedBasicContracts
+ );
+
+ if (!result.success) {
+ debugError('[ContractApprovalHandler] 계약승인요청 전송 실패', result.error);
+
+ // 전송 실패 시 상태를 원래대로 되돌림
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ throw new Error(result.error || '계약승인요청 전송에 실패했습니다.');
+ }
+
+ // 3. 전송 성공 시 상태를 'Contract Accept Request'로 변경
+ debugLog('[ContractApprovalHandler] 계약승인요청 전송 성공, 상태를 Contract Accept Request로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'Contract Accept Request',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ debugSuccess('[ContractApprovalHandler] 일반계약 승인 완료', {
+ contractId: payload.contractId,
+ result: result
+ });
+
+ return {
+ success: true,
+ message: '계약승인요청이 전송되었습니다.',
+ result: result
+ };
+ } catch (error) {
+ debugError('[ContractApprovalHandler] 일반계약 승인 중 에러', error);
+
+ // 에러 발생 시 상태를 원래대로 되돌림
+ try {
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+ } catch (updateError) {
+ debugError('[ContractApprovalHandler] 상태 업데이트 실패', updateError);
+ }
+
+ throw error;
+ }
+}
+
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 3f3dc8de..b803d2d4 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -504,7 +504,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentDelivery,
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
@@ -525,7 +525,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
-
+ console.log(updateData.paymentDelivery,"updateData.paymentDelivery")
// DB에 업데이트 실행
const [updatedContract] = await db
.update(generalContracts)
@@ -533,14 +533,9 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
- // 계약명 I/F 로직 (39번 화면으로의 I/F)
- // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
- // if (data.name) {
- // await syncContractNameToScreen39(id, data.name as string)
- // }
revalidatePath('/general-contracts')
- revalidatePath(`/general-contracts/detail/${id}`)
+ revalidatePath(`/general-contracts/${id}`)
return updatedContract
} catch (error) {
console.error('Error updating contract basic info:', error)
@@ -1391,7 +1386,7 @@ export async function sendContractApprovalRequest(
signerStatus: 'PENDING',
})
- // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 - 수정필요 12/05
if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
try {
// 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)
diff --git a/lib/information/service.ts b/lib/information/service.ts
index 02efe616..39e810e4 100644
--- a/lib/information/service.ts
+++ b/lib/information/service.ts
@@ -3,7 +3,7 @@
import { getErrorMessage } from "@/lib/handle-error"
import { desc, or, eq } from "drizzle-orm"
import db from "@/db/db"
-import { pageInformation, menuAssignments, users } from "@/db/schema"
+import { pageInformation, menuTreeNodes, users } from "@/db/schema"
import { saveDRMFile } from "@/lib/file-stroage"
import { decryptWithServerAction } from "@/components/drm/drmUtils"
@@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s
pagePath // 원본 경로 정확한 매칭
]
- // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기
- const menuAssignment = await db
+ // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기
+ const menuNode = await db
.select()
- .from(menuAssignments)
+ .from(menuTreeNodes)
.where(
or(
- ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path))
+ ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path))
)
)
.limit(1)
- if (menuAssignment.length === 0) {
+ if (menuNode.length === 0) {
// 매칭되는 메뉴가 없으면 권한 없음
return false
}
- const assignment = menuAssignment[0]
+ const node = menuNode[0]
const userIdNumber = parseInt(userId)
// 현재 사용자가 manager1 또는 manager2인지 확인
- return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber
+ return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber
} catch (error) {
console.error("Failed to check information edit permission:", error)
return false
@@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string)
return await checkInformationEditPermission(pagePath, userId)
}
-// menu_assignments 기반으로 page_information 동기화
+// menu_tree_nodes 기반으로 page_information 동기화
export async function syncInformationFromMenuAssignments() {
try {
- // menu_assignments에서 모든 메뉴 가져오기
- const menuItems = await db.select().from(menuAssignments);
+ // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것)
+ const menuItems = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.nodeType, 'menu'));
let processedCount = 0;
// upsert를 사용하여 각 메뉴 항목 처리
for (const menu of menuItems) {
try {
+ if (!menu.menuPath) continue;
+
// 맨 앞의 / 제거하여 pagePath 정규화
const normalizedPagePath = menu.menuPath.startsWith('/')
? menu.menuPath.slice(1)
@@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() {
await db.insert(pageInformation)
.values({
pagePath: normalizedPagePath,
- pageName: menu.menuTitle,
+ pageName: menu.titleKo,
informationContent: "",
isActive: true // 기본값으로 활성화
})
.onConflictDoUpdate({
target: pageInformation.pagePath,
set: {
- pageName: menu.menuTitle,
+ pageName: menu.titleKo,
updatedAt: new Date()
}
});
@@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() {
}
}
- // 캐시 무효화 제거됨
-
return {
success: true,
message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨`
diff --git a/lib/items-tech/table/add-items-dialog.tsx b/lib/items-tech/table/add-items-dialog.tsx
index 01a072da..a4c644b6 100644
--- a/lib/items-tech/table/add-items-dialog.tsx
+++ b/lib/items-tech/table/add-items-dialog.tsx
@@ -34,7 +34,7 @@ import {
} from "@/components/ui/select"
import { toast } from "sonner"
-import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem } from "../service"
+import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem, getShipTypes } from "../service"
import { ItemType } from "./delete-items-dialog"
// 조선 공종 유형 정의
@@ -88,6 +88,8 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
const router = useRouter()
const [open, setOpen] = React.useState(false)
const [isAddPending, startAddTransition] = React.useTransition()
+ const [shipTypeOptions, setShipTypeOptions] = React.useState<string[]>([])
+ const [isShipTypeLoading, setIsShipTypeLoading] = React.useState(false)
// 기본값 설정
const getDefaultValues = () => {
@@ -97,7 +99,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
}
if (itemType === 'shipbuilding') {
- defaults.shipTypes = "OPTION"
+ defaults.shipTypes = ""
} else {
defaults.itemList = ""
defaults.subItemList = ""
@@ -124,6 +126,42 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
defaultValues: getDefaultValues(),
})
+ // shipTypes 목록 로드 (조선 아이템 생성 시)
+ React.useEffect(() => {
+ if (itemType !== 'shipbuilding' || !open) return
+
+ let isMounted = true
+ const loadShipTypes = async () => {
+ try {
+ setIsShipTypeLoading(true)
+ const { data, error } = await getShipTypes()
+ if (!isMounted) return
+ if (error) {
+ toast.error("선종 목록을 불러오지 못했습니다")
+ return
+ }
+ const options = (data || []).filter((v): v is string => Boolean(v))
+ setShipTypeOptions(options)
+ // 기본값 자동 설정
+ if (options.length > 0 && !form.getValues("shipTypes")) {
+ form.setValue("shipTypes", options[0])
+ }
+ } catch (err) {
+ console.error("shipTypes load error:", err)
+ if (isMounted) {
+ toast.error("선종 목록 로드 중 오류가 발생했습니다")
+ }
+ } finally {
+ if (isMounted) setIsShipTypeLoading(false)
+ }
+ }
+
+ loadShipTypes()
+ return () => {
+ isMounted = false
+ }
+ }, [itemType, open, form])
+
const onSubmit = async (data: ItemFormValues) => {
startAddTransition(async () => {
try {
@@ -276,7 +314,28 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
<FormItem>
<FormLabel>선종 <span style={{ color: 'red' }}>*</span></FormLabel>
<FormControl>
- <Input placeholder="선종을 입력하세요" {...field} />
+ <Select
+ onValueChange={field.onChange}
+ value={field.value ?? ""}
+ disabled={isShipTypeLoading || shipTypeOptions.length === 0}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={isShipTypeLoading ? "불러오는 중..." : "선종을 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.length === 0 ? (
+ <div className="px-3 py-2 text-sm text-muted-foreground">
+ {isShipTypeLoading ? "불러오는 중..." : "선종 없음"}
+ </div>
+ ) : (
+ shipTypeOptions.map((type) => (
+ <SelectItem key={type} value={type}>
+ {type}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
</FormControl>
<FormMessage />
</FormItem>
diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx
new file mode 100644
index 00000000..b6762820
--- /dev/null
+++ b/lib/menu-v2/components/add-node-dialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type {
+ MenuDomain,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ CreateTopLevelMenuInput
+} from "../types";
+
+type DialogType = "menu_group" | "group" | "top_level_menu";
+
+interface AddNodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ type: DialogType;
+ domain: MenuDomain;
+ parentId?: number; // group 생성 시 필요
+ onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise<void>;
+}
+
+interface FormData {
+ titleKo: string;
+ titleEn: string;
+ menuPath: string;
+}
+
+export function AddNodeDialog({
+ open,
+ onOpenChange,
+ type,
+ domain,
+ parentId,
+ onSave,
+}: AddNodeDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting, errors },
+ } = useForm<FormData>({
+ defaultValues: {
+ titleKo: "",
+ titleEn: "",
+ menuPath: "",
+ },
+ });
+
+ const getTitle = () => {
+ switch (type) {
+ case "menu_group":
+ return "Add Menu Group";
+ case "group":
+ return "Add Group";
+ case "top_level_menu":
+ return "Add Top-Level Menu";
+ default:
+ return "Add";
+ }
+ };
+
+ const getDescription = () => {
+ switch (type) {
+ case "menu_group":
+ return "A dropdown trigger displayed in the header navigation.";
+ case "group":
+ return "Groups menus within a menu group.";
+ case "top_level_menu":
+ return "A single link displayed in the header navigation.";
+ default:
+ return "";
+ }
+ };
+
+ const onSubmit = async (data: FormData) => {
+ let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput;
+
+ if (type === "menu_group") {
+ saveData = {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ };
+ } else if (type === "group" && parentId) {
+ saveData = {
+ parentId,
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ };
+ } else if (type === "top_level_menu") {
+ saveData = {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ menuPath: data.menuPath,
+ };
+ } else {
+ return;
+ }
+
+ await onSave(saveData);
+ reset();
+ onOpenChange(false);
+ };
+
+ const handleClose = () => {
+ reset();
+ onOpenChange(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleClose}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>{getTitle()}</DialogTitle>
+ <DialogDescription>{getDescription()}</DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid gap-4">
+ {/* Korean Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleKo">Name (Korean) *</Label>
+ <Input
+ id="titleKo"
+ {...register("titleKo", { required: "Name is required" })}
+ placeholder="Master Data"
+ />
+ {errors.titleKo && (
+ <p className="text-xs text-destructive">{errors.titleKo.message}</p>
+ )}
+ </div>
+
+ {/* English Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleEn">Name (English)</Label>
+ <Input
+ id="titleEn"
+ {...register("titleEn")}
+ placeholder="Master Data"
+ />
+ </div>
+
+ {/* Menu Path for Top-Level Menu */}
+ {type === "top_level_menu" && (
+ <div className="grid gap-2">
+ <Label htmlFor="menuPath">Menu Path *</Label>
+ <Input
+ id="menuPath"
+ {...register("menuPath", {
+ required: type === "top_level_menu" ? "Path is required" : false
+ })}
+ placeholder={`/${domain}/dashboard`}
+ />
+ {errors.menuPath && (
+ <p className="text-xs text-destructive">{errors.menuPath.message}</p>
+ )}
+ <p className="text-xs text-muted-foreground">
+ e.g., /{domain}/report, /{domain}/faq
+ </p>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleClose}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx
new file mode 100644
index 00000000..e52fa80b
--- /dev/null
+++ b/lib/menu-v2/components/domain-tabs.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { MenuDomain } from "../types";
+
+interface DomainTabsProps {
+ value: MenuDomain;
+ onChange: (domain: MenuDomain) => void;
+}
+
+export function DomainTabs({ value, onChange }: DomainTabsProps) {
+ return (
+ <Tabs value={value} onValueChange={(v) => onChange(v as MenuDomain)}>
+ <TabsList>
+ <TabsTrigger value="evcp">
+ EVCP (Internal)
+ </TabsTrigger>
+ <TabsTrigger value="partners">
+ Partners (Vendors)
+ </TabsTrigger>
+ </TabsList>
+ </Tabs>
+ );
+}
+
diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx
new file mode 100644
index 00000000..9631a611
--- /dev/null
+++ b/lib/menu-v2/components/edit-node-dialog.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import type { MenuTreeNode, UpdateNodeInput } from "../types";
+
+interface EditNodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: MenuTreeNode | null;
+ onSave: (nodeId: number, data: UpdateNodeInput) => Promise<void>;
+}
+
+interface FormData {
+ titleKo: string;
+ titleEn: string;
+ descriptionKo: string;
+ descriptionEn: string;
+ scrId: string;
+ isActive: boolean;
+}
+
+export function EditNodeDialog({
+ open,
+ onOpenChange,
+ node,
+ onSave,
+}: EditNodeDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ setValue,
+ watch,
+ formState: { isSubmitting },
+ } = useForm<FormData>({
+ defaultValues: {
+ titleKo: "",
+ titleEn: "",
+ descriptionKo: "",
+ descriptionEn: "",
+ scrId: "",
+ isActive: true,
+ },
+ });
+
+ const isActive = watch("isActive");
+
+ useEffect(() => {
+ if (node) {
+ reset({
+ titleKo: node.titleKo,
+ titleEn: node.titleEn || "",
+ descriptionKo: node.descriptionKo || "",
+ descriptionEn: node.descriptionEn || "",
+ scrId: node.scrId || "",
+ isActive: node.isActive,
+ });
+ }
+ }, [node, reset]);
+
+ const onSubmit = async (data: FormData) => {
+ if (!node) return;
+
+ await onSave(node.id, {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ descriptionKo: data.descriptionKo || undefined,
+ descriptionEn: data.descriptionEn || undefined,
+ scrId: data.scrId || undefined,
+ isActive: data.isActive,
+ });
+
+ onOpenChange(false);
+ };
+
+ const getTypeLabel = () => {
+ switch (node?.nodeType) {
+ case "menu_group":
+ return "Menu Group";
+ case "group":
+ return "Group";
+ case "menu":
+ return "Menu";
+ case "additional":
+ return "Additional Menu";
+ default:
+ return "Node";
+ }
+ };
+
+ const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional";
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle>Edit {getTypeLabel()}</DialogTitle>
+ <DialogDescription>
+ {node?.menuPath && (
+ <span className="text-xs text-muted-foreground">{node.menuPath}</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid gap-4">
+ {/* Korean Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleKo">Name (Korean) *</Label>
+ <Input
+ id="titleKo"
+ {...register("titleKo", { required: true })}
+ placeholder="Project List"
+ />
+ </div>
+
+ {/* English Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleEn">Name (English)</Label>
+ <Input
+ id="titleEn"
+ {...register("titleEn")}
+ placeholder="Project List"
+ />
+ </div>
+
+ {/* Korean Description */}
+ {showMenuFields && (
+ <div className="grid gap-2">
+ <Label htmlFor="descriptionKo">Description (Korean)</Label>
+ <Textarea
+ id="descriptionKo"
+ {...register("descriptionKo")}
+ placeholder="Project list from MDG (C)"
+ rows={2}
+ />
+ </div>
+ )}
+
+ {/* English Description */}
+ {showMenuFields && (
+ <div className="grid gap-2">
+ <Label htmlFor="descriptionEn">Description (English)</Label>
+ <Textarea
+ id="descriptionEn"
+ {...register("descriptionEn")}
+ placeholder="Project list from MDG (C)"
+ rows={2}
+ />
+ </div>
+ )}
+
+ {/* Permission SCR_ID */}
+ {showMenuFields && (
+ <div className="grid gap-2">
+ <Label htmlFor="scrId">Permission SCR_ID (EVCP only)</Label>
+ <Input
+ id="scrId"
+ {...register("scrId")}
+ placeholder="SCR_001"
+ />
+ <p className="text-xs text-muted-foreground">
+ Linked with Oracle DB SCR_ID. If empty, auto-matched by URL.
+ </p>
+ </div>
+ )}
+
+ {/* Active Status */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-0.5">
+ <Label htmlFor="isActive">Show in Menu</Label>
+ <p className="text-xs text-muted-foreground">
+ When disabled, hidden from the navigation menu.
+ </p>
+ </div>
+ <Switch
+ id="isActive"
+ checked={isActive}
+ onCheckedChange={(checked) => setValue("isActive", checked)}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Saving..." : "Save"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/menu-v2/components/menu-tree-manager.tsx b/lib/menu-v2/components/menu-tree-manager.tsx
new file mode 100644
index 00000000..337eaee4
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree-manager.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import { useState, useEffect, useCallback, useTransition } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { RefreshCw, Plus, Loader2 } from "lucide-react";
+import { DomainTabs } from "./domain-tabs";
+import { MenuTree } from "./menu-tree";
+import { EditNodeDialog } from "./edit-node-dialog";
+import { AddNodeDialog } from "./add-node-dialog";
+import { MoveToDialog } from "./move-to-dialog";
+import { UnassignedMenusPanel } from "./unassigned-menus-panel";
+import {
+ getMenuTreeForAdmin,
+ createMenuGroup,
+ createGroup,
+ createTopLevelMenu,
+ updateNode,
+ moveNodeUp,
+ moveNodeDown,
+ moveNodeToParent,
+ getAvailableParents,
+ assignMenuToGroup,
+ activateAsTopLevelMenu,
+ syncDiscoveredMenus,
+} from "../service";
+import type {
+ MenuDomain,
+ MenuTreeNode,
+ MenuTreeAdminResult,
+ UpdateNodeInput,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ CreateTopLevelMenuInput,
+} from "../types";
+
+interface MenuTreeManagerProps {
+ initialDomain?: MenuDomain;
+}
+
+export function MenuTreeManager({ initialDomain = "evcp" }: MenuTreeManagerProps) {
+ const [domain, setDomain] = useState<MenuDomain>(initialDomain);
+ const [data, setData] = useState<MenuTreeAdminResult | null>(null);
+ const [isInitialLoading, setIsInitialLoading] = useState(true);
+ const [isPending, startTransition] = useTransition();
+
+ // Dialog states
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
+ const [editingNode, setEditingNode] = useState<MenuTreeNode | null>(null);
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [addDialogType, setAddDialogType] = useState<"menu_group" | "group" | "top_level_menu">("menu_group");
+ const [addGroupParentId, setAddGroupParentId] = useState<number | undefined>(undefined);
+
+ // Move dialog state
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
+ const [movingNode, setMovingNode] = useState<MenuTreeNode | null>(null);
+ const [availableParents, setAvailableParents] = useState<{ id: number | null; title: string; depth: number }[]>([]);
+
+ // Tree expansion state
+ const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
+
+ // Load data using server action
+ const loadData = useCallback(async (isRefresh = false) => {
+ if (!isRefresh) {
+ setIsInitialLoading(true);
+ }
+ try {
+ const result = await getMenuTreeForAdmin(domain);
+ setData(result);
+ } catch (error) {
+ console.error("Error loading menu tree:", error);
+ toast.error("Failed to load menu tree");
+ } finally {
+ setIsInitialLoading(false);
+ }
+ }, [domain]);
+
+ useEffect(() => {
+ setExpandedIds(new Set());
+ loadData();
+ }, [loadData]);
+
+ const handleSync = async () => {
+ startTransition(async () => {
+ try {
+ const result = await syncDiscoveredMenus(domain);
+ toast.success(`Sync complete: ${result.added} menus added`);
+ loadData(true);
+ } catch (error) {
+ console.error("Error syncing menus:", error);
+ toast.error("Failed to sync menus");
+ }
+ });
+ };
+
+ const handleEdit = (node: MenuTreeNode) => {
+ setEditingNode(node);
+ setEditDialogOpen(true);
+ };
+
+ const handleSaveEdit = async (nodeId: number, input: UpdateNodeInput) => {
+ startTransition(async () => {
+ try {
+ await updateNode(nodeId, input);
+ toast.success("Saved successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error updating node:", error);
+ toast.error("Failed to save");
+ }
+ });
+ };
+
+ // Move up (within same parent)
+ const handleMoveUp = async (nodeId: number) => {
+ startTransition(async () => {
+ try {
+ await moveNodeUp(nodeId);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node up:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ // Move down (within same parent)
+ const handleMoveDown = async (nodeId: number) => {
+ startTransition(async () => {
+ try {
+ await moveNodeDown(nodeId);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node down:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ // Open move to dialog
+ const handleOpenMoveDialog = async (node: MenuTreeNode) => {
+ setMovingNode(node);
+ try {
+ const parents = await getAvailableParents(node.id, domain, node.nodeType);
+ setAvailableParents(parents);
+ setMoveDialogOpen(true);
+ } catch (error) {
+ console.error("Error loading available parents:", error);
+ toast.error("Failed to load move options");
+ }
+ };
+
+ // Execute move to different parent
+ const handleMoveTo = async (newParentId: number | null) => {
+ if (!movingNode) return;
+ startTransition(async () => {
+ try {
+ await moveNodeToParent(movingNode.id, newParentId);
+ toast.success("Moved successfully");
+ setMoveDialogOpen(false);
+ setMovingNode(null);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ const handleAddMenuGroup = () => {
+ setAddDialogType("menu_group");
+ setAddGroupParentId(undefined);
+ setAddDialogOpen(true);
+ };
+
+ const handleAddGroup = (parentId: number) => {
+ setAddDialogType("group");
+ setAddGroupParentId(parentId);
+ setAddDialogOpen(true);
+ };
+
+ const handleAddTopLevelMenu = () => {
+ setAddDialogType("top_level_menu");
+ setAddGroupParentId(undefined);
+ setAddDialogOpen(true);
+ };
+
+ const handleSaveAdd = async (
+ input: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput
+ ) => {
+ startTransition(async () => {
+ try {
+ if (addDialogType === "menu_group") {
+ await createMenuGroup(domain, input as CreateMenuGroupInput);
+ } else if (addDialogType === "group") {
+ await createGroup(domain, input as CreateGroupInput);
+ } else if (addDialogType === "top_level_menu") {
+ await createTopLevelMenu(domain, input as CreateTopLevelMenuInput);
+ }
+ toast.success("Created successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error creating node:", error);
+ toast.error("Failed to create");
+ }
+ });
+ };
+
+ const handleAssign = async (menuId: number, groupId: number) => {
+ startTransition(async () => {
+ try {
+ await assignMenuToGroup(menuId, groupId);
+ toast.success("Assigned successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error assigning menu:", error);
+ toast.error("Failed to assign");
+ }
+ });
+ };
+
+ const handleActivateAsTopLevel = async (menuId: number) => {
+ startTransition(async () => {
+ try {
+ await activateAsTopLevelMenu(menuId);
+ toast.success("Activated as top-level menu");
+ loadData(true);
+ } catch (error) {
+ console.error("Error activating as top level:", error);
+ toast.error("Failed to activate");
+ }
+ });
+ };
+
+ // Build list of available groups for assignment
+ const getAvailableGroups = () => {
+ if (!data) return [];
+
+ const groups: { id: number; title: string; parentTitle?: string }[] = [];
+
+ for (const node of data.tree) {
+ if (node.nodeType !== 'menu_group') continue;
+
+ groups.push({ id: node.id, title: node.titleKo });
+
+ if (node.children) {
+ for (const child of node.children) {
+ if (child.nodeType === "group") {
+ groups.push({
+ id: child.id,
+ title: child.titleKo,
+ parentTitle: node.titleKo,
+ });
+ }
+ }
+ }
+ }
+
+ return groups;
+ };
+
+ if (isInitialLoading) {
+ return (
+ <div className="flex items-center justify-center h-96">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between">
+ <DomainTabs value={domain} onChange={setDomain} />
+ <div className="flex items-center gap-2">
+ {/* [jh] I've commented this button.. */}
+ {/* <Button variant="outline" size="sm" onClick={handleSync} disabled={isPending}>
+ <RefreshCw className={`mr-2 h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
+ Sync Pages
+ </Button> */}
+ <Button variant="outline" size="sm" onClick={handleAddTopLevelMenu} disabled={isPending}>
+ <Plus className="mr-2 h-4 w-4" />
+ Add Top-Level Menu
+ </Button>
+ <Button size="sm" onClick={handleAddMenuGroup} disabled={isPending}>
+ <Plus className="mr-2 h-4 w-4" />
+ Add Menu Group
+ </Button>
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ {/* Menu Tree */}
+ <div className="lg:col-span-2">
+ <Card>
+ <CardHeader>
+ <CardTitle>{domain === "evcp" ? "EVCP" : "Partners"} Menu Structure</CardTitle>
+ <CardDescription>
+ Use arrow buttons to reorder, or click Move To to change parent.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {data?.tree && data.tree.length > 0 ? (
+ <MenuTree
+ nodes={data.tree}
+ onEdit={handleEdit}
+ onMoveUp={handleMoveUp}
+ onMoveDown={handleMoveDown}
+ onMoveTo={handleOpenMoveDialog}
+ onAddGroup={handleAddGroup}
+ expandedIds={expandedIds}
+ onExpandedIdsChange={setExpandedIds}
+ isPending={isPending}
+ />
+ ) : (
+ <p className="text-sm text-muted-foreground text-center py-8">
+ No menus. Add one using the buttons above.
+ </p>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* Unassigned Menus */}
+ <div className="lg:col-span-1">
+ <UnassignedMenusPanel
+ menus={data?.unassigned || []}
+ onAssign={handleAssign}
+ onActivateAsTopLevel={handleActivateAsTopLevel}
+ onEdit={handleEdit}
+ availableGroups={getAvailableGroups()}
+ />
+ </div>
+ </div>
+
+ {/* Dialogs */}
+ <EditNodeDialog
+ open={editDialogOpen}
+ onOpenChange={setEditDialogOpen}
+ node={editingNode}
+ onSave={handleSaveEdit}
+ />
+
+ <AddNodeDialog
+ open={addDialogOpen}
+ onOpenChange={setAddDialogOpen}
+ type={addDialogType}
+ domain={domain}
+ parentId={addGroupParentId}
+ onSave={handleSaveAdd}
+ />
+
+ <MoveToDialog
+ open={moveDialogOpen}
+ onOpenChange={setMoveDialogOpen}
+ node={movingNode}
+ availableParents={availableParents}
+ onMove={handleMoveTo}
+ />
+ </div>
+ );
+}
diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx
new file mode 100644
index 00000000..7d3ab077
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useCallback } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ ChevronRight,
+ ChevronDown,
+ ChevronUp,
+ Folder,
+ FolderOpen,
+ File,
+ Pencil,
+ Plus,
+ ArrowUpDown,
+ EyeOff,
+} from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MenuTreeProps {
+ nodes: MenuTreeNode[];
+ onEdit: (node: MenuTreeNode) => void;
+ onMoveUp: (nodeId: number) => void;
+ onMoveDown: (nodeId: number) => void;
+ onMoveTo: (node: MenuTreeNode) => void;
+ onAddGroup: (parentId: number) => void;
+ expandedIds: Set<number>;
+ onExpandedIdsChange: (ids: Set<number>) => void;
+ isPending?: boolean;
+}
+
+interface TreeItemProps {
+ node: MenuTreeNode;
+ depth: number;
+ isFirst: boolean;
+ isLast: boolean;
+ onEdit: (node: MenuTreeNode) => void;
+ onMoveUp: (nodeId: number) => void;
+ onMoveDown: (nodeId: number) => void;
+ onMoveTo: (node: MenuTreeNode) => void;
+ onAddGroup: (parentId: number) => void;
+ isExpanded: boolean;
+ onToggleExpand: () => void;
+ isPending?: boolean;
+}
+
+function TreeItem({
+ node,
+ depth,
+ isFirst,
+ isLast,
+ onEdit,
+ onMoveUp,
+ onMoveDown,
+ onMoveTo,
+ onAddGroup,
+ isExpanded,
+ onToggleExpand,
+ isPending,
+}: TreeItemProps) {
+ const isMenuGroup = node.nodeType === "menu_group";
+ const isGroup = node.nodeType === "group";
+ const isMenu = node.nodeType === "menu";
+ const isTopLevel = node.parentId === null;
+ const hasChildren = node.children && node.children.length > 0;
+ const isExpandable = isMenuGroup || isGroup;
+
+ // Move To is disabled for:
+ // - menu_group (always at top level, cannot be moved)
+ // - top-level menu (parentId === null, can only reorder with up/down)
+ const canMoveTo = !isMenuGroup && !isTopLevel;
+
+ const getIcon = () => {
+ if (isMenuGroup || isGroup) {
+ return isExpanded ? (
+ <FolderOpen className="h-4 w-4 text-amber-500" />
+ ) : (
+ <Folder className="h-4 w-4 text-amber-500" />
+ );
+ }
+ return <File className="h-4 w-4 text-slate-500" />;
+ };
+
+ const getTypeLabel = () => {
+ switch (node.nodeType) {
+ case "menu_group": return "Menu Group";
+ case "group": return "Group";
+ case "menu": return "Menu";
+ default: return "";
+ }
+ };
+
+ return (
+ <div
+ className={cn(
+ "flex items-center gap-2 px-2 py-1.5 rounded-md border bg-background hover:bg-accent/50 transition-colors",
+ !node.isActive && "opacity-50 bg-muted/30 border-dashed"
+ )}
+ style={{ marginLeft: depth * 24 }}
+ >
+ {/* Expand/Collapse */}
+ {isExpandable ? (
+ <button
+ onClick={onToggleExpand}
+ className="p-0.5 hover:bg-accent rounded shrink-0"
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+ ) : (
+ <div className="w-5 shrink-0" />
+ )}
+
+ {/* Icon */}
+ {getIcon()}
+
+ {/* Title */}
+ <span className={cn(
+ "flex-1 text-sm font-medium truncate min-w-0",
+ !node.isActive && "line-through text-muted-foreground"
+ )}>
+ {node.titleKo}
+ {node.titleEn && (
+ <span className="text-muted-foreground font-normal"> [{node.titleEn}]</span>
+ )}
+ </span>
+
+ {/* Hidden indicator */}
+ {!node.isActive && (
+ <EyeOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" title="Hidden" />
+ )}
+
+ {/* Path (for menus) */}
+ {isMenu && node.menuPath && (
+ <span className="text-xs text-muted-foreground truncate max-w-[150px] shrink-0">
+ {node.menuPath}
+ </span>
+ )}
+
+ {/* Type Badge */}
+ <Badge variant={node.isActive ? "default" : "secondary"} className="text-xs shrink-0">
+ {getTypeLabel()}
+ </Badge>
+
+ {/* Active indicator */}
+ <div
+ className={cn(
+ "w-2 h-2 rounded-full shrink-0",
+ node.isActive ? "bg-green-500" : "bg-gray-400"
+ )}
+ title={node.isActive ? "Visible" : "Hidden"}
+ />
+
+ {/* Actions */}
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Move Up */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onMoveUp(node.id)}
+ disabled={isFirst || isPending}
+ title="Move Up"
+ >
+ <ChevronUp className="h-4 w-4" />
+ </Button>
+
+ {/* Move Down */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onMoveDown(node.id)}
+ disabled={isLast || isPending}
+ title="Move Down"
+ >
+ <ChevronDown className="h-4 w-4" />
+ </Button>
+
+ {/* Move To (different parent) - disabled for top level nodes */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onMoveTo(node)}
+ disabled={!canMoveTo || isPending}
+ title={canMoveTo ? "Move To..." : "Cannot move top-level items"}
+ >
+ <ArrowUpDown className="h-4 w-4" />
+ </Button>
+
+ {/* Edit */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onEdit(node)}
+ disabled={isPending}
+ title="Edit"
+ >
+ <Pencil className="h-4 w-4" />
+ </Button>
+
+ {/* Add Sub-Group (for menu groups only) */}
+ {isMenuGroup && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onAddGroup(node.id)}
+ disabled={isPending}
+ title="Add Sub-Group"
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function MenuTree({
+ nodes,
+ onEdit,
+ onMoveUp,
+ onMoveDown,
+ onMoveTo,
+ onAddGroup,
+ expandedIds,
+ onExpandedIdsChange,
+ isPending,
+}: MenuTreeProps) {
+ const toggleExpand = useCallback((nodeId: number) => {
+ const next = new Set(expandedIds);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ onExpandedIdsChange(next);
+ }, [expandedIds, onExpandedIdsChange]);
+
+ const renderTree = (nodeList: MenuTreeNode[], depth: number) => {
+ return nodeList.map((node, index) => {
+ const isExpanded = expandedIds.has(node.id);
+ const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group";
+ const hasChildren = node.children && node.children.length > 0;
+
+ return (
+ <div key={node.id} className="space-y-1">
+ <TreeItem
+ node={node}
+ depth={depth}
+ isFirst={index === 0}
+ isLast={index === nodeList.length - 1}
+ onEdit={onEdit}
+ onMoveUp={onMoveUp}
+ onMoveDown={onMoveDown}
+ onMoveTo={onMoveTo}
+ onAddGroup={onAddGroup}
+ isExpanded={isExpanded}
+ onToggleExpand={() => toggleExpand(node.id)}
+ isPending={isPending}
+ />
+ {isExpandable && isExpanded && hasChildren && (
+ <div className="space-y-1">
+ {renderTree(node.children!, depth + 1)}
+ </div>
+ )}
+ </div>
+ );
+ });
+ };
+
+ return <div className="space-y-1">{renderTree(nodes, 0)}</div>;
+}
+
+
diff --git a/lib/menu-v2/components/move-to-dialog.tsx b/lib/menu-v2/components/move-to-dialog.tsx
new file mode 100644
index 00000000..7253708b
--- /dev/null
+++ b/lib/menu-v2/components/move-to-dialog.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { Folder, FolderOpen, Home } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MoveToDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: MenuTreeNode | null;
+ availableParents: { id: number | null; title: string; depth: number }[];
+ onMove: (newParentId: number | null) => void;
+}
+
+export function MoveToDialog({
+ open,
+ onOpenChange,
+ node,
+ availableParents,
+ onMove,
+}: MoveToDialogProps) {
+ if (!node) return null;
+
+ const isCurrent = (parentId: number | null) => node.parentId === parentId;
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Move To</DialogTitle>
+ <DialogDescription>
+ Select a new location for &quot;{node.titleKo}&quot;
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[400px]">
+ <div className="space-y-0.5 p-1">
+ {availableParents.map((parent) => (
+ <Button
+ key={parent.id ?? 'root'}
+ variant={isCurrent(parent.id) ? "secondary" : "ghost"}
+ className={cn(
+ "w-full justify-start h-auto py-2 text-sm",
+ parent.depth === 0 && "font-medium",
+ parent.depth === 1 && "font-medium",
+ parent.depth === 2 && "text-muted-foreground"
+ )}
+ style={{ paddingLeft: parent.depth * 20 + 8 }}
+ onClick={() => onMove(parent.id)}
+ disabled={isCurrent(parent.id)}
+ >
+ {parent.id === null ? (
+ <Home className="mr-2 h-4 w-4 text-blue-500 shrink-0" />
+ ) : parent.depth === 1 ? (
+ <FolderOpen className="mr-2 h-4 w-4 text-amber-500 shrink-0" />
+ ) : (
+ <Folder className="mr-2 h-4 w-4 text-amber-400 shrink-0" />
+ )}
+ <span className="truncate">{parent.title}</span>
+ {isCurrent(parent.id) && (
+ <span className="ml-auto text-xs text-muted-foreground shrink-0">(current)</span>
+ )}
+ </Button>
+ ))}
+ </div>
+ </ScrollArea>
+
+ <div className="flex justify-end">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+
diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx
new file mode 100644
index 00000000..2c914f2a
--- /dev/null
+++ b/lib/menu-v2/components/unassigned-menus-panel.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface UnassignedMenusPanelProps {
+ menus: MenuTreeNode[];
+ onAssign: (menuId: number, groupId: number) => void;
+ onActivateAsTopLevel: (menuId: number) => void;
+ onEdit: (menu: MenuTreeNode) => void;
+ availableGroups: { id: number; title: string; parentTitle?: string }[];
+}
+
+export function UnassignedMenusPanel({
+ menus,
+ onAssign,
+ onActivateAsTopLevel,
+ onEdit,
+ availableGroups,
+}: UnassignedMenusPanelProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedMenu, setSelectedMenu] = useState<number | null>(null);
+
+ const filteredMenus = menus.filter(
+ (menu) =>
+ menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+ <Card className="h-full">
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base flex items-center gap-2">
+ <FileQuestion className="h-4 w-4" />
+ Unassigned Menus ({menus.length})
+ </CardTitle>
+ <CardDescription>
+ Assign to a group or activate as a top-level link.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ {/* Search */}
+ <div className="relative">
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="Search..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+
+ {/* Menu List */}
+ <ScrollArea className="h-[400px]">
+ <div className="space-y-2">
+ {filteredMenus.length === 0 ? (
+ <p className="text-sm text-muted-foreground text-center py-4">
+ {searchTerm ? "No results found." : "No unassigned menus."}
+ </p>
+ ) : (
+ filteredMenus.map((menu) => (
+ <div
+ key={menu.id}
+ className={cn(
+ "p-3 rounded-md border bg-background hover:bg-accent/50 transition-colors",
+ selectedMenu === menu.id && "ring-2 ring-primary"
+ )}
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-sm">{menu.titleKo}</span>
+ <Badge variant="secondary" className="text-xs">
+ Inactive
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground truncate mt-1">
+ {menu.menuPath}
+ </p>
+ </div>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7 shrink-0"
+ onClick={() => onEdit(menu)}
+ >
+ <Pencil className="h-3.5 w-3.5" />
+ </Button>
+ </div>
+
+ {/* Group Selection (expanded) */}
+ {selectedMenu === menu.id ? (
+ <div className="mt-3 pt-3 border-t space-y-2">
+ {/* Activate as Top-Level */}
+ <div>
+ <p className="text-xs text-muted-foreground mb-2">
+ Activate as top-level link:
+ </p>
+ <Button
+ variant="default"
+ size="sm"
+ className="text-xs h-7"
+ onClick={() => {
+ onActivateAsTopLevel(menu.id);
+ setSelectedMenu(null);
+ }}
+ >
+ <Link className="mr-1 h-3 w-3" />
+ Activate as Top-Level
+ </Button>
+ </div>
+
+ {/* Assign to Group */}
+ {availableGroups.length > 0 && (
+ <div>
+ <p className="text-xs text-muted-foreground mb-2">
+ Or assign to group:
+ </p>
+ <div className="flex flex-wrap gap-1">
+ {availableGroups.map((group) => (
+ <Button
+ key={group.id}
+ variant="outline"
+ size="sm"
+ className="text-xs h-7"
+ onClick={() => {
+ onAssign(menu.id, group.id);
+ setSelectedMenu(null);
+ }}
+ >
+ {group.parentTitle && (
+ <span className="text-muted-foreground mr-1">
+ {group.parentTitle} &gt;
+ </span>
+ )}
+ {group.title}
+ <ArrowRight className="ml-1 h-3 w-3" />
+ </Button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <Button
+ variant="ghost"
+ size="sm"
+ className="text-xs"
+ onClick={() => setSelectedMenu(null)}
+ >
+ Cancel
+ </Button>
+ </div>
+ ) : (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="mt-2 text-xs w-full"
+ onClick={() => setSelectedMenu(menu.id)}
+ >
+ Assign / Activate
+ </Button>
+ )}
+ </div>
+ ))
+ )}
+ </div>
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts
new file mode 100644
index 00000000..e495ba23
--- /dev/null
+++ b/lib/menu-v2/permission-service.ts
@@ -0,0 +1,186 @@
+'use server';
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db";
+import { getActiveMenuTree } from "./service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types";
+import db from "@/db/db";
+import { users } from "@/db/schema/users";
+import { eq } from "drizzle-orm";
+
+/**
+ * Oracle 권한 체크 스킵 여부 확인
+ * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀
+ */
+function shouldSkipOraclePermissionCheck(): boolean {
+ return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true';
+}
+
+/**
+ * 사용자 ID로 employeeNumber 조회
+ */
+async function getEmployeeNumberByUserId(userId: number): Promise<string | null> {
+ const [user] = await db.select({ employeeNumber: users.employeeNumber })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ return user?.employeeNumber || null;
+}
+
+/**
+ * Get menu tree filtered by user permissions
+ *
+ * @param domain - Domain (evcp | partners)
+ * @param userId - Optional user ID. If not provided, gets from session.
+ *
+ * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check
+ */
+export async function getVisibleMenuTree(
+ domain: MenuDomain,
+ userId?: number
+): Promise<MenuTreeActiveResult> {
+ const { tree: menuTree } = await getActiveMenuTree(domain);
+
+ // Partners domain uses its own permission system (not implemented)
+ if (domain === 'partners') {
+ return { tree: menuTree };
+ }
+
+ // Skip Oracle permission check in development
+ if (shouldSkipOraclePermissionCheck()) {
+ return { tree: menuTree };
+ }
+
+ // Get userId from session if not provided
+ let effectiveUserId = userId;
+ if (!effectiveUserId) {
+ const session = await getServerSession(authOptions);
+ effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined;
+ }
+
+ if (!effectiveUserId) {
+ return { tree: menuTree };
+ }
+
+ // Get employeeNumber from userId
+ const empNo = await getEmployeeNumberByUserId(effectiveUserId);
+ if (!empNo) {
+ return { tree: menuTree };
+ }
+
+ let screens: ScreenEvcp[];
+ let userRoles: RoleRelEvcp[];
+
+ try {
+ [screens, userRoles] = await Promise.all([
+ getAllScreens(),
+ getUserRoles(empNo)
+ ]);
+ } catch (error) {
+ // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지)
+ console.error('[menu-v2] Oracle permission check failed, returning all menus:', error);
+ return { tree: menuTree };
+ }
+
+ const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+ const screenMap = new Map<string, ScreenEvcp>(screens.map(s => [s.SCR_URL, s]));
+
+ // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리)
+ async function filterByPermission(nodes: MenuTreeNode[]): Promise<MenuTreeNode[]> {
+ const result: MenuTreeNode[] = [];
+
+ for (const node of nodes) {
+ // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ const screen = screenMap.get(node.menuPath);
+
+ // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시
+ if (!screen || screen.SCRT_CHK_YN === 'N') {
+ result.push(node);
+ continue;
+ }
+
+ // SCRT_CHK_YN === 'Y' 이면 권한 체크
+ if (screen.SCRT_CHK_YN === 'Y') {
+ const scrIdToCheck = node.scrId || screen.SCR_ID;
+ const auths = await getAuthsByScreenId(scrIdToCheck);
+
+ const hasAccess = auths.some(auth => {
+ if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+ if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+ return false;
+ });
+
+ if (hasAccess) result.push(node);
+ }
+ }
+ // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함)
+ else if (node.nodeType === 'menu_group' || node.nodeType === 'group') {
+ const filteredChildren = await filterByPermission(node.children || []);
+ if (filteredChildren.length > 0) {
+ result.push({ ...node, children: filteredChildren });
+ }
+ }
+ }
+
+ return result;
+ }
+
+ const filteredTree = await filterByPermission(menuTree);
+
+ return { tree: filteredTree };
+}
+
+/**
+ * 특정 메뉴 경로에 대한 접근 권한 확인
+ *
+ * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환
+ */
+export async function checkMenuAccess(
+ menuPath: string,
+ userId: number
+): Promise<boolean> {
+ // Oracle 권한 체크 스킵 설정된 경우
+ if (shouldSkipOraclePermissionCheck()) {
+ return true;
+ }
+
+ const empNo = await getEmployeeNumberByUserId(userId);
+ if (!empNo) return false;
+
+ try {
+ const screens = await getAllScreens();
+ const screen = screens.find(s => s.SCR_URL === menuPath);
+
+ // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면
+ if (!screen || screen.SCRT_CHK_YN === 'N') {
+ return true;
+ }
+
+ // 삭제된 화면
+ if (screen.DEL_YN === 'Y') {
+ return false;
+ }
+
+ // 권한 체크
+ const [auths, userRoles] = await Promise.all([
+ getAuthsByScreenId(screen.SCR_ID),
+ getUserRoles(empNo)
+ ]);
+
+ const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+
+ return auths.some(auth => {
+ if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+ if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+ return false;
+ });
+ } catch (error) {
+ // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지)
+ console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error);
+ return true;
+ }
+}
+
diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts
new file mode 100644
index 00000000..39ca144a
--- /dev/null
+++ b/lib/menu-v2/service.ts
@@ -0,0 +1,605 @@
+'use server';
+
+import fs from 'fs';
+import path from 'path';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import { eq, and, asc, inArray, isNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import type {
+ MenuDomain,
+ MenuTreeNode,
+ MenuTreeAdminResult,
+ MenuTreeActiveResult,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ UpdateNodeInput,
+ ReorderNodeInput,
+ DiscoveredMenu
+} from "./types";
+import { DOMAIN_APP_PATHS } from "./types";
+
+// 도메인별 전체 트리 조회 (관리 화면용)
+export async function getMenuTreeForAdmin(domain: MenuDomain): Promise<MenuTreeAdminResult> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.domain, domain))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // 트리에 포함될 노드들:
+ // - menu_group (최상위 드롭다운)
+ // - group (드롭다운 내 그룹)
+ // - 배정된 menu (parentId !== null)
+ // - 최상위 menu (parentId === null, isActive === true) - 단일 링크
+ const treeNodes = nodes.filter(n =>
+ n.nodeType === 'menu_group' ||
+ n.nodeType === 'group' ||
+ (n.nodeType === 'menu' && n.parentId !== null) ||
+ (n.nodeType === 'menu' && n.parentId === null && n.isActive)
+ ) as MenuTreeNode[];
+
+ const tree = buildTree(treeNodes);
+
+ // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu)
+ const unassigned = nodes.filter(n =>
+ n.nodeType === 'menu' && n.parentId === null && !n.isActive
+ ) as MenuTreeNode[];
+
+ return { tree, unassigned };
+}
+
+// 도메인별 활성 트리 조회 (헤더용)
+export async function getActiveMenuTree(domain: MenuDomain): Promise<MenuTreeActiveResult> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ eq(menuTreeNodes.isActive, true)
+ ))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // 트리에 포함될 노드들:
+ // - menu_group (최상위 드롭다운)
+ // - group (드롭다운 내 그룹)
+ // - 배정된 menu (parentId !== null)
+ // - 최상위 menu (parentId === null) - 단일 링크
+ const treeNodes = nodes.filter(n =>
+ n.nodeType === 'menu_group' ||
+ n.nodeType === 'group' ||
+ n.nodeType === 'menu'
+ ) as MenuTreeNode[];
+
+ const tree = buildTree(treeNodes);
+
+ return { tree };
+}
+
+// 메뉴그룹 생성 (드롭다운)
+export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 그룹 생성 (메뉴그룹 하위)
+export async function createGroup(domain: MenuDomain, data: CreateGroupInput) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: data.parentId,
+ nodeType: 'group',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할)
+export async function createTopLevelMenu(domain: MenuDomain, data: {
+ titleKo: string;
+ titleEn?: string;
+ menuPath: string;
+ sortOrder?: number;
+}) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ menuPath: data.menuPath,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 노드 이동 (드래그앤드롭)
+export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: newParentId,
+ sortOrder: newSortOrder,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 수정
+export async function updateNode(nodeId: number, data: UpdateNodeInput) {
+ await db.update(menuTreeNodes)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 삭제
+export async function deleteNode(nodeId: number) {
+ const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1);
+
+ if (!node) return;
+
+ if (node.nodeType === 'menu') {
+ // 최상위 메뉴(parentId === null)는 직접 삭제 가능
+ // 하위 메뉴(parentId !== null)는 미배정으로
+ if (node.parentId === null) {
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ }
+ } else {
+ // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로
+ const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType })
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.parentId, nodeId));
+
+ for (const child of children) {
+ if (child.nodeType === 'menu') {
+ // 메뉴는 미배정으로
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, child.id));
+ } else if (child.nodeType === 'group') {
+ // 그룹의 하위 메뉴도 미배정으로
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.parentId, child.id));
+
+ // 그룹 삭제
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id));
+ }
+ }
+
+ // 본 노드 삭제
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 순서 일괄 변경
+export async function reorderNodes(updates: ReorderNodeInput[]) {
+ for (const { id, sortOrder } of updates) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, id));
+ }
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 특정 그룹에 배정
+export async function assignMenuToGroup(menuId: number, groupId: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: groupId,
+ isActive: true,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, menuId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 최상위 메뉴로 활성화
+export async function activateAsTopLevelMenu(menuId: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: null,
+ isActive: true,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, menuId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 단일 노드 조회
+export async function getNodeById(nodeId: number): Promise<MenuTreeNode | null> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ return node as MenuTreeNode | null;
+}
+
+// Helper: Convert flat list to tree
+function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] {
+ const nodeMap = new Map<number, MenuTreeNode>();
+ const roots: MenuTreeNode[] = [];
+
+ nodes.forEach(node => {
+ nodeMap.set(node.id, { ...node, children: [] });
+ });
+
+ nodes.forEach(node => {
+ const current = nodeMap.get(node.id)!;
+ if (node.parentId === null) {
+ roots.push(current);
+ } else {
+ const parent = nodeMap.get(node.parentId);
+ if (parent) {
+ if (!parent.children) parent.children = [];
+ parent.children.push(current);
+ }
+ }
+ });
+
+ const sortChildren = (nodes: MenuTreeNode[]) => {
+ nodes.sort((a, b) => a.sortOrder - b.sortOrder);
+ nodes.forEach(node => {
+ if (node.children?.length) {
+ sortChildren(node.children);
+ }
+ });
+ };
+ sortChildren(roots);
+
+ return roots;
+}
+
+// ============================================
+// Menu Discovery & Sync (Server Actions)
+// ============================================
+
+const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/;
+
+/**
+ * Discover pages from app router for a specific domain
+ */
+function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] {
+ const { appDir, basePath } = DOMAIN_APP_PATHS[domain];
+ const menus: DiscoveredMenu[] = [];
+
+ function scanDirectory(dir: string, currentPath: string[], routeGroup: string) {
+ const absoluteDir = path.resolve(process.cwd(), dir);
+
+ if (!fs.existsSync(absoluteDir)) return;
+
+ const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(absoluteDir, entry.name);
+
+ if (entry.isDirectory()) {
+ if (entry.name.startsWith('(') && entry.name.endsWith(')')) {
+ scanDirectory(fullPath, currentPath, entry.name);
+ }
+ else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) {
+ continue;
+ }
+ else {
+ scanDirectory(fullPath, [...currentPath, entry.name], routeGroup);
+ }
+ }
+ else if (entry.name === 'page.tsx') {
+ const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : '');
+ menus.push({
+ domain,
+ menuPath,
+ pageFilePath: fullPath,
+ routeGroup
+ });
+ }
+ }
+ }
+
+ scanDirectory(appDir, [], '');
+ return menus;
+}
+
+/**
+ * Sync discovered menus for a specific domain
+ */
+export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> {
+ const discovered = discoverMenusFromAppRouter(domain);
+
+ const existing = await db.select({
+ id: menuTreeNodes.id,
+ menuPath: menuTreeNodes.menuPath
+ })
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ inArray(menuTreeNodes.nodeType, ['menu', 'additional'])
+ ));
+
+ const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean));
+
+ const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath));
+ let added = 0;
+
+ for (const menu of newMenus) {
+ const pathSegments = menu.menuPath.split('/').filter(Boolean);
+ const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown';
+
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu',
+ sortOrder: 0,
+ titleKo: lastSegment,
+ titleEn: lastSegment,
+ menuPath: menu.menuPath,
+ isActive: false,
+ });
+ added++;
+ }
+
+ revalidatePath('/evcp/menu-v2');
+ return { added, removed: 0 };
+}
+
+/**
+ * Sync all domains
+ */
+export async function syncAllDomains(): Promise<Record<MenuDomain, { added: number; removed: number }>> {
+ const [evcp, partners] = await Promise.all([
+ syncDiscoveredMenus('evcp'),
+ syncDiscoveredMenus('partners')
+ ]);
+ return { evcp, partners };
+}
+
+/**
+ * Get discovered menus without syncing
+ */
+export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> {
+ return {
+ evcp: discoverMenusFromAppRouter('evcp'),
+ partners: discoverMenusFromAppRouter('partners')
+ };
+}
+
+// ============================================
+// Move Node Helpers
+// ============================================
+
+/**
+ * Move node up within same parent (decrease sort order)
+ */
+export async function moveNodeUp(nodeId: number): Promise<void> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get siblings (nodes with same parent)
+ const siblings = await db.select()
+ .from(menuTreeNodes)
+ .where(node.parentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, node.parentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // Find current index
+ const currentIndex = siblings.findIndex(s => s.id === nodeId);
+ if (currentIndex <= 0) return; // Already at top
+
+ // Swap sort orders with previous node
+ const prevNode = siblings[currentIndex - 1];
+ const prevSortOrder = prevNode.sortOrder;
+ const currentSortOrder = node.sortOrder;
+
+ // If sort orders are the same, assign unique values
+ if (prevSortOrder === currentSortOrder) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex - 1, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, prevNode.id));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: prevSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, prevNode.id));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node down within same parent (increase sort order)
+ */
+export async function moveNodeDown(nodeId: number): Promise<void> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get siblings (nodes with same parent)
+ const siblings = await db.select()
+ .from(menuTreeNodes)
+ .where(node.parentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, node.parentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // Find current index
+ const currentIndex = siblings.findIndex(s => s.id === nodeId);
+ if (currentIndex >= siblings.length - 1) return; // Already at bottom
+
+ // Swap sort orders with next node
+ const nextNode = siblings[currentIndex + 1];
+ const nextSortOrder = nextNode.sortOrder;
+ const currentSortOrder = node.sortOrder;
+
+ // If sort orders are the same, assign unique values
+ if (nextSortOrder === currentSortOrder) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex + 1, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nextNode.id));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: nextSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nextNode.id));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node to a different parent
+ */
+export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise<void> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get max sort order in new parent
+ const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder })
+ .from(menuTreeNodes)
+ .where(newParentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, newParentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0;
+
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: newParentId,
+ sortOrder: maxSortOrder,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Get all possible parent targets for a node (for Move To dialog)
+ * Returns items in tree order (same as Menu Structure display)
+ *
+ * Rules:
+ * - menu_group: Cannot be moved (always at top level)
+ * - group: Can only move to menu_group (not to root or other groups)
+ * - menu: Can move to root, menu_group, or group
+ */
+export async function getAvailableParents(
+ nodeId: number,
+ domain: MenuDomain,
+ nodeType: string
+): Promise<{ id: number | null; title: string; depth: number }[]> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ inArray(menuTreeNodes.nodeType, ['menu_group', 'group'])
+ ))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ const result: { id: number | null; title: string; depth: number }[] = [];
+
+ // For menu nodes, allow moving to root (as top-level menu)
+ if (nodeType === 'menu') {
+ result.push({ id: null, title: 'Top Level (Root)', depth: 0 });
+ }
+
+ // Build tree structure
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
+ const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group');
+
+ // Helper to check if node is descendant of nodeId (prevent circular reference)
+ const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => {
+ let parent = checkNode.parentId;
+ while (parent !== null) {
+ if (parent === ancestorId) return true;
+ const parentNode = nodeMap.get(parent);
+ parent = parentNode?.parentId ?? null;
+ }
+ return false;
+ };
+
+ // Traverse tree in order (menu_group -> its children groups)
+ for (const menuGroup of menuGroups) {
+ // Skip if it's the node being moved or its descendant
+ if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue;
+
+ // Add menu_group
+ result.push({
+ id: menuGroup.id,
+ title: menuGroup.titleKo,
+ depth: 1
+ });
+
+ // For group nodes, only menu_groups are valid targets (skip children)
+ if (nodeType === 'group') continue;
+
+ // Add children groups (sorted by sortOrder)
+ const childGroups = nodes
+ .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group')
+ .sort((a, b) => a.sortOrder - b.sortOrder);
+
+ for (const group of childGroups) {
+ // Skip if it's the node being moved or its descendant
+ if (group.id === nodeId || isDescendantOf(group, nodeId)) continue;
+
+ result.push({
+ id: group.id,
+ title: group.titleKo,
+ depth: 2
+ });
+ }
+ }
+
+ return result;
+}
diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts
new file mode 100644
index 00000000..1be8a4fe
--- /dev/null
+++ b/lib/menu-v2/types.ts
@@ -0,0 +1,103 @@
+// lib/menu-v2/types.ts
+
+export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional';
+export type MenuDomain = 'evcp' | 'partners';
+
+export interface MenuTreeNode {
+ id: number;
+ domain: MenuDomain;
+ parentId: number | null;
+ nodeType: NodeType;
+ sortOrder: number;
+ titleKo: string;
+ titleEn: string | null;
+ descriptionKo: string | null;
+ descriptionEn: string | null;
+ menuPath: string | null;
+ icon: string | null;
+ scrId: string | null;
+ isActive: boolean;
+ manager1Id: number | null;
+ manager2Id: number | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 조회 시 추가되는 필드
+ children?: MenuTreeNode[];
+}
+
+export interface DiscoveredMenu {
+ domain: MenuDomain;
+ menuPath: string;
+ pageFilePath: string;
+ routeGroup: string;
+}
+
+// 도메인별 앱 라우터 경로 설정
+export const DOMAIN_APP_PATHS: Record<MenuDomain, {
+ appDir: string;
+ basePath: string;
+}> = {
+ evcp: {
+ appDir: 'app/[lng]/evcp/(evcp)',
+ basePath: '/evcp'
+ },
+ partners: {
+ appDir: 'app/[lng]/partners',
+ basePath: '/partners'
+ }
+};
+
+// 관리자용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeAdminResult {
+ tree: MenuTreeNode[];
+ unassigned: MenuTreeNode[];
+}
+
+// 헤더용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeActiveResult {
+ tree: MenuTreeNode[];
+}
+
+// 노드 생성 타입
+export interface CreateMenuGroupInput {
+ titleKo: string;
+ titleEn?: string;
+ sortOrder?: number;
+}
+
+export interface CreateGroupInput {
+ parentId: number;
+ titleKo: string;
+ titleEn?: string;
+ sortOrder?: number;
+}
+
+// 최상위 메뉴 생성 (단일 링크)
+export interface CreateTopLevelMenuInput {
+ titleKo: string;
+ titleEn?: string;
+ menuPath: string;
+ sortOrder?: number;
+}
+
+// 노드 업데이트 타입
+export interface UpdateNodeInput {
+ titleKo?: string;
+ titleEn?: string;
+ descriptionKo?: string;
+ descriptionEn?: string;
+ isActive?: boolean;
+ scrId?: string;
+ icon?: string;
+ manager1Id?: number | null;
+ manager2Id?: number | null;
+}
+
+// 순서 변경 타입
+export interface ReorderNodeInput {
+ id: number;
+ sortOrder: number;
+}
+
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts
index b62eb8df..c91959a9 100644
--- a/lib/procurement-items/service.ts
+++ b/lib/procurement-items/service.ts
@@ -255,8 +255,19 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
unstable_noStore()
try {
+ // 검색어가 없으면 상위 50개 반환
if (!query || query.trim().length < 1) {
- return []
+ const results = await db
+ .select({
+ itemCode: procurementItems.itemCode,
+ itemName: procurementItems.itemName,
+ })
+ .from(procurementItems)
+ .where(eq(procurementItems.isActive, 'Y'))
+ .limit(50)
+ .orderBy(asc(procurementItems.itemCode))
+
+ return results
}
const searchQuery = `%${query.trim()}%`
@@ -277,7 +288,7 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
eq(procurementItems.isActive, 'Y')
)
)
- .limit(20)
+ .limit(50)
.orderBy(asc(procurementItems.itemCode))
return results
diff --git a/lib/sedp/get-tags-plant.ts b/lib/sedp/get-tags-plant.ts
index be0e398b..f804ebe9 100644
--- a/lib/sedp/get-tags-plant.ts
+++ b/lib/sedp/get-tags-plant.ts
@@ -3,651 +3,578 @@ import {
tagsPlant,
formsPlant,
formEntriesPlant,
- items,
- tagTypeClassFormMappings,
projects,
tagTypes,
tagClasses,
} from "@/db/schema";
-import { eq, and, like, inArray } from "drizzle-orm";
-import { revalidateTag } from "next/cache"; // 추가
+import { eq, and } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
import { getSEDPToken } from "./sedp-token";
-/**
- * 태그 가져오기 서비스 함수
- * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장
- * TAG_IDX를 기준으로 태그를 식별합니다.
- *
- * @param projectCode 계약 아이템 ID (contractItemId)
- * @param packageCode 계약 아이템 ID (contractItemId)
- * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수
- * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등)
- */
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+// ============ 타입 정의 ============
+interface newRegister {
+ PROJ_NO: string;
+ MAP_ID: string;
+ EP_ID: string;
+ DESC: string;
+ CATEGORY: string;
+ BYPASS: boolean;
+ REG_TYPE_ID: string;
+ TOOL_ID: string;
+ TOOL_TYPE: string;
+ SCOPES: string[];
+ MAP_CLS: {
+ TOOL_ATT_NAME: string;
+ ITEMS: any[];
+ };
+ MAP_ATT: any[];
+ MAP_TMPLS: string[];
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string;
+ _id: string;
+}
+
+interface Register {
+ PROJ_NO: string;
+ TYPE_ID: string;
+ EP_ID: string;
+ DESC: string;
+ REMARK: string | null;
+ NEW_TAG_YN: boolean;
+ ALL_TAG_YN: boolean;
+ VND_YN: boolean;
+ SEQ: number;
+ CMPLX_YN: boolean;
+ CMPL_SETT: any | null;
+ MAP_ATT: any[];
+ MAP_CLS_ID: string[];
+ MAP_OPER: any | null;
+ LNK_ATT: any[];
+ JOIN_TABLS: any[];
+ DELETED: boolean;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface FormInfo {
+ formCode: string;
+ formName: string;
+ im: boolean;
+ eng: boolean;
+}
+
+// ============ API 호출 함수들 ============
+
+async function getNewRegisters(projectCode: string): Promise<newRegister[]> {
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ TOOL_ID: "eVCP"
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`새 레지스터 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const registers: newRegister[] = Array.isArray(data) ? data : [data];
+
+ console.log(`[getNewRegisters] 프로젝트 ${projectCode}에서 ${registers.length}개의 레지스터를 가져왔습니다.`);
+ return registers;
+}
+
+async function getRegisters(projectCode: string): Promise<Register[]> {
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Register/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const registers: Register[] = Array.isArray(data) ? data : [data];
+
+ console.log(`[getRegisters] 프로젝트 ${projectCode}에서 ${registers.length}개의 레지스터를 가져왔습니다.`);
+ return registers;
+}
+
+async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> {
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Data/GetPubData`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ REG_TYPE_ID: formCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP API 요청 실패: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ return await response.json();
+}
+
+async function getRegisterDetail(projectCode: string, formCode: string): Promise<Register | null> {
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Register/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ TYPE_ID: formCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ console.error(`Register detail 요청 실패: ${formCode}`);
+ return null;
+ }
+
+ return await response.json();
+}
+
+// ============ 메인 함수 ============
+
export async function importTagsFromSEDP(
projectCode: string,
packageCode: string,
- progressCallback?: (progress: number) => void,
- mode?: string
+ progressCallback?: (progress: number) => void
): Promise<{
processedCount: number;
excludedCount: number;
totalEntries: number;
errors?: string[];
}> {
+ const allErrors: string[] = [];
+ let totalProcessedCount = 0;
+ let totalExcludedCount = 0;
+ let totalEntriesCount = 0;
+
try {
- // 진행 상황 보고
if (progressCallback) progressCallback(5);
+ // Step 1: 프로젝트 ID 조회
const project = await db.query.projects.findFirst({
where: eq(projects.code, projectCode),
- columns: {
- id: true
- }
+ columns: { id: true }
});
+ if (!project) {
+ throw new Error(`Project not found: ${projectCode}`);
+ }
+ const projectId = project.id;
+
+ if (progressCallback) progressCallback(10);
- // 프로젝트 ID 획득
- const projectId = project?.id;
+ // Step 2: 두 API 동시 호출
+ const [newRegisters, registers] = await Promise.all([
+ getNewRegisters(projectCode),
+ getRegisters(projectCode)
+ ]);
- // Step 1-2: Get the item using itemId from contractItem
- const item = await db.query.items.findFirst({
- where: and(eq(items.ProjectNo, projectCode), eq(items.packageCode, packageCode))
+ if (progressCallback) progressCallback(20);
+
+ // ======== 서브클래스 매핑을 위한 태그 클래스 로드 ========
+ const allTagClasses = await db.query.tagClasses.findMany({
+ where: eq(tagClasses.projectId, projectId)
});
- if (!item) {
- throw new Error(`Item with ID ${item?.id} not found`);
+ // 클래스 코드로 빠른 조회를 위한 Map
+ const tagClassByCode = new Map(allTagClasses.map(tc => [tc.code, tc]));
+
+ // 서브클래스 코드로 부모 클래스 찾기 위한 Map
+ const parentBySubclassCode = new Map<string, typeof allTagClasses[0]>();
+ for (const tc of allTagClasses) {
+ if (tc.subclasses && Array.isArray(tc.subclasses)) {
+ for (const sub of tc.subclasses as { id: string; desc: string }[]) {
+ parentBySubclassCode.set(sub.id, tc);
+ }
+ }
}
- const itemCode = item.itemCode;
+ console.log(`[importTagsFromSEDP] 태그 클래스 ${allTagClasses.length}개 로드, 서브클래스 매핑 ${parentBySubclassCode.size}개 생성`);
+ // ======== 서브클래스 매핑 준비 완료 ========
- // 진행 상황 보고
- if (progressCallback) progressCallback(10);
+ // Step 3: packageCode에 해당하는 폼 정보 추출
+ const formsToProcess: FormInfo[] = [];
- // 기본 매핑 검색 - 모든 모드에서 사용
- const baseMappings = await db.query.tagTypeClassFormMappings.findMany({
- where: and(
- like(tagTypeClassFormMappings.remark, `%${itemCode}%`),
- eq(tagTypeClassFormMappings.projectId, projectId)
- )
- });
-
- if (baseMappings.length === 0) {
- throw new Error(`No mapping found for item code ${itemCode}`);
+ // Register 정보를 Map으로 변환 (TYPE_ID로 빠른 조회)
+ const registerMap = new Map<string, Register>();
+ for (const reg of registers) {
+ registerMap.set(reg.TYPE_ID, reg);
}
- // Step 2: Find the mapping entries - 모드에 따라 다른 조건 적용
- let mappings = [];
-
- if (mode === 'IM') {
- // IM 모드일 때는 먼저 SEDP에서 태그 데이터를 가져와 TAG_TYPE_ID 리스트 확보
-
- // 프로젝트 코드 가져오기
- const project = await db.query.projects.findFirst({
- where: eq(projects.id, projectId)
- });
-
- if (!project) {
- throw new Error(`Project with ID ${projectId} not found`);
- }
-
- // 각 매핑의 formCode에 대해 태그 데이터 조회
- const tagTypeIds = new Set<string>();
-
- for (const mapping of baseMappings) {
- try {
- // SEDP에서 태그 데이터 가져오기
- const tagData = await fetchTagDataFromSEDP(project.code, mapping.formCode);
-
- // 첫 번째 키를 테이블 이름으로 사용
- const tableName = Object.keys(tagData)[0];
- const tagEntries = tagData[tableName];
-
- if (Array.isArray(tagEntries)) {
- // 모든 태그에서 TAG_TYPE_ID 수집
- for (const entry of tagEntries) {
- if (entry.TAG_TYPE_ID && entry.TAG_TYPE_ID !== "") {
- tagTypeIds.add(entry.TAG_TYPE_ID);
- }
- }
- }
- } catch (error) {
- console.error(`Error fetching tag data for formCode ${mapping.formCode}:`, error);
- }
- }
-
- if (tagTypeIds.size === 0) {
- throw new Error('No valid TAG_TYPE_ID found in SEDP tag data');
- }
-
- // 수집된 TAG_TYPE_ID로 tagTypes에서 정보 조회
- const tagTypeInfo = await db.query.tagTypes.findMany({
- where: and(
- inArray(tagTypes.code, Array.from(tagTypeIds)),
- eq(tagTypes.projectId, projectId)
- )
- });
-
- if (tagTypeInfo.length === 0) {
- throw new Error('No matching tag types found for the collected TAG_TYPE_IDs');
- }
-
- // 태그 타입 설명 수집
- const tagLabels = tagTypeInfo.map(tt => tt.description);
-
- // IM 모드에 맞는 매핑 조회 - ep가 "IMEP"인 항목만
- mappings = await db.query.tagTypeClassFormMappings.findMany({
- where: and(
- inArray(tagTypeClassFormMappings.tagTypeLabel, tagLabels),
- eq(tagTypeClassFormMappings.projectId, projectId),
- eq(tagTypeClassFormMappings.ep, "IMEP")
- )
- });
-
- } else {
- // ENG 모드 또는 기본 모드일 때 - 기본 매핑 사용
- mappings = [...baseMappings];
-
- // ENG 모드에서는 ep 필드가 "IMEP"가 아닌 매핑만 필터링
- if (mode === 'ENG') {
- mappings = mappings.filter(mapping => mapping.ep !== "IMEP");
+ // newRegisters에서 packageCode가 SCOPES에 포함된 것 필터링
+ for (const newReg of newRegisters) {
+ if (newReg.SCOPES && newReg.SCOPES.includes(packageCode)) {
+ const formCode = newReg.REG_TYPE_ID;
+ const formName = newReg.DESC;
+
+ // Register에서 EP_ID 확인하여 im/eng 결정
+ const register = registerMap.get(formCode);
+ const isIM = register?.EP_ID === "IMEP";
+
+ formsToProcess.push({
+ formCode,
+ formName,
+ im: isIM,
+ eng: !isIM
+ });
}
}
- // 매핑이 없는 경우 모드에 따라 다른 오류 메시지 사용
- if (mappings.length === 0) {
- if (mode === 'IM') {
- throw new Error('No suitable mappings found for IM mode');
- } else {
- throw new Error(`No mapping found for item code ${itemCode}`);
- }
+ if (formsToProcess.length === 0) {
+ throw new Error(`No forms found for packageCode: ${packageCode}`);
}
-
- // 진행 상황 보고
- if (progressCallback) progressCallback(15);
-
- // 결과 누적을 위한 변수들 초기화
- let totalProcessedCount = 0;
- let totalExcludedCount = 0;
- let totalEntriesCount = 0;
- const allErrors: string[] = [];
-
- // 각 매핑에 대해 처리
- for (let mappingIndex = 0; mappingIndex < mappings.length; mappingIndex++) {
- const mapping = mappings[mappingIndex];
-
- // Step 3: Get the project code
- const project = await db.query.projects.findFirst({
- where: eq(projects.id, mapping.projectId)
- });
-
- if (!project) {
- allErrors.push(`Project with ID ${mapping.projectId} not found`);
- continue; // 다음 매핑으로 진행
- }
- // IM 모드에서는 baseMappings에서 같은 formCode를 가진 매핑을 찾음
- let formCode = mapping.formCode;
- if (mode === 'IM') {
- // baseMapping에서 동일한 formCode를 가진 매핑 찾기
- const originalMapping = baseMappings.find(
- baseMapping => baseMapping.formCode === mapping.formCode
- );
-
- // 찾았으면 해당 formCode 사용, 못 찾았으면 현재 매핑의 formCode 유지
- if (originalMapping) {
- formCode = originalMapping.formCode;
- }
- }
+ console.log(`[importTagsFromSEDP] ${formsToProcess.length}개의 폼을 처리합니다.`);
- // 진행 상황 보고 - 매핑별 진행률 조정
- if (progressCallback) {
- const baseProgress = 15;
- const mappingProgress = Math.floor(15 * (mappingIndex + 1) / mappings.length);
- progressCallback(baseProgress + mappingProgress);
- }
+ if (progressCallback) progressCallback(25);
- // Step 4: Find the form ID
- const form = await db.query.formsPlant.findFirst({
- where: and(
- eq(formsPlant.projectCode, projectCode),
- eq(formsPlant.formCode, formCode),
- eq(formsPlant.packageCode, packageCode)
- )
- });
-
- let formId;
-
- // If form doesn't exist, create it
- if (!form) {
- // 폼이 없는 경우 새로 생성 - 모드에 따른 필드 설정
- const insertValues: any = {
- projectCode,
- packageCode,
- formCode: formCode,
- formName: mapping.formName
- };
-
- // 모드 정보가 있으면 해당 필드 설정
- if (mode) {
- if (mode === "ENG") {
- insertValues.eng = true;
- } else if (mode === "IM") {
- insertValues.im = true;
- if (mapping.remark && mapping.remark.includes("VD_")) {
- insertValues.eng = true;
- }
- }
- }
+ // Step 4: 각 폼에 대해 처리
+ for (let i = 0; i < formsToProcess.length; i++) {
+ const formInfo = formsToProcess[i];
+ const { formCode, formName, im, eng } = formInfo;
- const insertResult = await db.insert(formsPlant)
- .values(insertValues)
- .onConflictDoUpdate({
- target: [formsPlant.projectCode, formsPlant.formCode],
- set: {
- packageCode: insertValues.packageCode,
- formName: insertValues.formName,
- eng: insertValues.eng ?? false,
- im: insertValues.im ?? false,
- updatedAt: new Date()
- }
- })
- .returning({ id: formsPlant.id });
+ try {
+ // 진행률 계산
+ const baseProgress = 25;
+ const progressPerForm = 70 / formsToProcess.length;
- if (insertResult.length === 0) {
- allErrors.push(`Failed to create form record for formCode ${formCode}`);
- continue; // 다음 매핑으로 진행
- }
-
- formId = insertResult[0].id;
- } else {
- // 폼이 이미 존재하는 경우 - 필요시 모드 필드 업데이트
- formId = form.id;
-
- if (mode) {
- let shouldUpdate = false;
- const updateValues: any = {};
-
- if (mode === "ENG" && form.eng !== true) {
- updateValues.eng = true;
- shouldUpdate = true;
- } else if (mode === "IM" && form.im !== true) {
- updateValues.im = true;
- shouldUpdate = true;
- }
-
- if (shouldUpdate) {
- await db.update(formsPlant)
- .set({
- ...updateValues,
- updatedAt: new Date()
- })
- .where(eq(formsPlant.id, formId));
+ // Step 4-1: formsPlant upsert
+ const existingForm = await db.query.formsPlant.findFirst({
+ where: and(
+ eq(formsPlant.projectCode, projectCode),
+ eq(formsPlant.packageCode, packageCode),
+ eq(formsPlant.formCode, formCode)
+ )
+ });
+
+ let formId: number;
+
+ if (existingForm) {
+ // 기존 폼 업데이트
+ await db.update(formsPlant)
+ .set({
+ formName,
+ im,
+ eng,
+ updatedAt: new Date()
+ })
+ .where(eq(formsPlant.id, existingForm.id));
+
+ formId = existingForm.id;
+ console.log(`[formsPlant] Updated form: ${formCode}`);
+ } else {
+ // 새 폼 생성
+ const insertResult = await db.insert(formsPlant)
+ .values({
+ projectCode,
+ packageCode,
+ formCode,
+ formName,
+ im,
+ eng
+ })
+ .returning({ id: formsPlant.id });
- console.log(`Updated form ${formId} with ${mode} mode enabled`);
- }
+ formId = insertResult[0].id;
+ console.log(`[formsPlant] Created form: ${formCode}`);
}
- }
-
- // 진행 상황 보고 - 매핑별 진행률 조정
- if (progressCallback) {
- const baseProgress = 30;
- const mappingProgress = Math.floor(20 * (mappingIndex + 1) / mappings.length);
- progressCallback(baseProgress + mappingProgress);
- }
- try {
- // Step 5: Call the external API to get tag data
- const tagData = await fetchTagDataFromSEDP(projectCode, baseMappings[0].formCode);
-
- // 진행 상황 보고
if (progressCallback) {
- const baseProgress = 50;
- const mappingProgress = Math.floor(10 * (mappingIndex + 1) / mappings.length);
- progressCallback(baseProgress + mappingProgress);
+ progressCallback(baseProgress + progressPerForm * (i + 0.2));
}
- // Step 6: Process the data and insert into the tags table
- let processedCount = 0;
- let excludedCount = 0;
-
- // Get the first key from the response as the table name
+ // Step 4-2: SEDP에서 태그 데이터 가져오기
+ const tagData = await fetchTagDataFromSEDP(projectCode, formCode);
const tableName = Object.keys(tagData)[0];
const tagEntries = tagData[tableName];
if (!Array.isArray(tagEntries) || tagEntries.length === 0) {
- allErrors.push(`No tag data found in the API response for formCode ${baseMappings[0].formCode}`);
- continue; // 다음 매핑으로 진행
+ console.log(`[importTagsFromSEDP] No tag data for formCode: ${formCode}`);
+ continue;
}
- const entriesCount = tagEntries.length;
- totalEntriesCount += entriesCount;
-
- // formEntries를 위한 데이터 수집
- const newTagsForFormEntry: Array<{
- TAG_IDX: string; // 변경: TAG_NO → TAG_IDX
- TAG_NO?: string; // TAG_NO도 함께 저장 (편집 가능한 필드)
- TAG_DESC: string | null;
- status: string;
- [key: string]: any;
- }> = [];
- const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
- const apiKey = await getSEDPToken();
-
- const registerResponse = await fetch(
- `${SEDP_API_BASE_URL}/Register/GetByID`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'accept': '*/*',
- 'ApiKey': apiKey,
- 'ProjectNo': projectCode
- },
- body: JSON.stringify({
- ProjectNo: projectCode,
- TYPE_ID: baseMappings[0].formCode, // 또는 mapping.formCode
- ContainDeleted: false
- })
- }
- )
-
- if (!registerResponse.ok) {
- allErrors.push(`Failed to fetch register details for ${baseMappings[0].formCode}`)
- continue
+ totalEntriesCount += tagEntries.length;
+
+ if (progressCallback) {
+ progressCallback(baseProgress + progressPerForm * (i + 0.4));
}
-
- const registerDetail: Register = await registerResponse.json()
+
+ // Step 4-3: Register detail에서 허용된 ATT_ID 추출
+ const registerDetail = await getRegisterDetail(projectCode, formCode);
+ const allowedAttIds = new Set<string>();
- // ✅ MAP_ATT에서 허용된 ATT_ID 목록 추출
- const allowedAttIds = new Set<string>()
- if (Array.isArray(registerDetail.MAP_ATT)) {
+ if (registerDetail?.MAP_ATT && Array.isArray(registerDetail.MAP_ATT)) {
for (const mapAttr of registerDetail.MAP_ATT) {
if (mapAttr.ATT_ID) {
- allowedAttIds.add(mapAttr.ATT_ID)
+ allowedAttIds.add(mapAttr.ATT_ID);
}
}
}
-
- // Process each tag entry
- for (let i = 0; i < tagEntries.length; i++) {
- try {
- const entry = tagEntries[i];
-
- // TAG_IDX가 없는 경우 제외 (변경: TAG_NO → TAG_IDX 체크)
- if (!entry.TAG_IDX) {
- excludedCount++;
- totalExcludedCount++;
-
- // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트)
- if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) {
- const baseProgress = 60;
- const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount)));
- progressCallback(baseProgress + entryProgress);
- }
+ // Step 4-4: 태그 처리
+ const newTagsForFormEntry: Array<Record<string, any>> = [];
+ let processedCount = 0;
+ let excludedCount = 0;
- continue; // 이 항목은 건너뜀
- }
+ for (const entry of tagEntries) {
+ // TAG_IDX 없으면 제외
+ if (!entry.TAG_IDX) {
+ excludedCount++;
+ continue;
+ }
+
+ // TAG_TYPE_ID 없으면 제외
+ if (!entry.TAG_TYPE_ID || entry.TAG_TYPE_ID === "") {
+ excludedCount++;
+ continue;
+ }
- const attributes: Record<string, string> = {}
- if (Array.isArray(entry.ATTRIBUTES)) {
- for (const attr of entry.ATTRIBUTES) {
- // MAP_ATT에 정의된 ATT_ID만 포함
- if (attr.ATT_ID && allowedAttIds.has(attr.ATT_ID)) {
- if (attr.VALUE !== null && attr.VALUE !== undefined) {
- attributes[attr.ATT_ID] = String(attr.VALUE)
- }
+ // attributes 추출 (허용된 ATT_ID만)
+ const attributes: Record<string, string> = {};
+ if (Array.isArray(entry.ATTRIBUTES)) {
+ for (const attr of entry.ATTRIBUTES) {
+ if (attr.ATT_ID && allowedAttIds.has(attr.ATT_ID)) {
+ if (attr.VALUE !== null && attr.VALUE !== undefined) {
+ attributes[attr.ATT_ID] = String(attr.VALUE);
}
}
}
-
-
- // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외
- if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") {
- excludedCount++;
- totalExcludedCount++;
-
- // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트)
- if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) {
- const baseProgress = 60;
- const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount)));
- progressCallback(baseProgress + entryProgress);
- }
+ }
- continue; // 이 항목은 건너뜀
+ // tagType 조회
+ const tagType = await db.query.tagTypes.findFirst({
+ where: and(
+ eq(tagTypes.code, entry.TAG_TYPE_ID),
+ eq(tagTypes.projectId, projectId)
+ )
+ });
+
+ // ======== 클래스 및 서브클래스 결정 로직 ========
+ let classLabel: string;
+ let subclassValue: string | null = null;
+ let tagClassId: number | null = null;
+
+ // 1. 먼저 CLS_ID로 직접 tagClass 찾기
+ const tagClass = tagClassByCode.get(entry.CLS_ID);
+
+ if (tagClass) {
+ // 직접 찾은 경우 - 이게 메인 클래스
+ classLabel = tagClass.label || entry.CLS_ID;
+ tagClassId = tagClass.id;
+ } else {
+ // 2. 서브클래스인지 확인 (부모 클래스의 subclasses 배열에 있는지)
+ const parentClass = parentBySubclassCode.get(entry.CLS_ID);
+
+ if (parentClass) {
+ // 서브클래스인 경우
+ classLabel = parentClass.label || parentClass.code;
+ subclassValue = entry.CLS_ID;
+ tagClassId = parentClass.id;
+
+ console.log(`[importTagsFromSEDP] 서브클래스 발견: ${entry.CLS_ID} -> 부모: ${parentClass.code}`);
+ } else {
+ // 어디에도 없는 경우 - 원본 값 사용
+ classLabel = entry.CLS_ID;
+ console.log(`[importTagsFromSEDP] 클래스를 찾을 수 없음: ${entry.CLS_ID}`);
}
-
- // Get tag type description
- const tagType = await db.query.tagTypes.findFirst({
- where: and(
- eq(tagTypes.code, entry.TAG_TYPE_ID),
- eq(tagTypes.projectId, mapping.projectId)
- )
- });
-
- // Get tag class label
- const tagClass = await db.query.tagClasses.findFirst({
- where: and(
- eq(tagClasses.code, entry.CLS_ID),
- eq(tagClasses.projectId, mapping.projectId)
- )
- });
-
- // Insert or update the tag - tagIdx 필드 추가
- await db.insert(tagsPlant).values({
- projectCode,
- packageCode,
- formId: formId,
- tagIdx: entry.TAG_IDX,
+ }
+ // ======== 클래스/서브클래스 결정 완료 ========
+
+ // tagsPlant upsert (subclass 필드 추가)
+ await db.insert(tagsPlant).values({
+ projectCode,
+ packageCode,
+ formId,
+ tagIdx: entry.TAG_IDX,
+ tagNo: entry.TAG_NO || entry.TAG_IDX,
+ tagType: tagType?.description || entry.TAG_TYPE_ID,
+ tagClassId: tagClassId,
+ class: classLabel,
+ subclass: subclassValue,
+ description: entry.TAG_DESC,
+ attributes,
+ }).onConflictDoUpdate({
+ target: [tagsPlant.projectCode, tagsPlant.packageCode, tagsPlant.tagIdx],
+ set: {
+ formId,
tagNo: entry.TAG_NO || entry.TAG_IDX,
tagType: tagType?.description || entry.TAG_TYPE_ID,
- tagClassId: tagClass?.id,
- class: tagClass?.label || entry.CLS_ID,
+ tagClassId: tagClassId,
+ class: classLabel,
+ subclass: subclassValue,
description: entry.TAG_DESC,
- attributes: attributes, // JSONB로 저장
- }).onConflictDoUpdate({
- target: [tagsPlant.projectCode, tagsPlant.packageCode, tagsPlant.tagIdx],
- set: {
- formId: formId,
- tagNo: entry.TAG_NO || entry.TAG_IDX,
- tagType: tagType?.description || entry.TAG_TYPE_ID,
- class: tagClass?.label || entry.CLS_ID,
- description: entry.TAG_DESC,
- attributes: attributes, // JSONB 업데이트
- updatedAt: new Date()
- }
- })
- // formEntries용 데이터 수집
- const tagDataForFormEntry = {
- TAG_IDX: entry.TAG_IDX, // 변경: TAG_NO → TAG_IDX
- TAG_NO: entry.TAG_NO || entry.TAG_IDX, // TAG_NO도 함께 저장
- TAG_DESC: entry.TAG_DESC || null,
- status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시
- source: "S-EDP" // 태그 출처 (불변) - S-EDP에서 가져옴
- };
-
- // ATTRIBUTES가 있으면 추가 (SHI 필드들)
- if (Array.isArray(entry.ATTRIBUTES)) {
- for (const attr of entry.ATTRIBUTES) {
- if (attr.ATT_ID && attr.VALUE !== null && attr.VALUE !== undefined) {
- tagDataForFormEntry[attr.ATT_ID] = attr.VALUE;
- }
+ attributes,
+ updatedAt: new Date()
+ }
+ });
+
+ // formEntriesPlant용 데이터 준비
+ const tagDataForFormEntry: Record<string, any> = {
+ TAG_IDX: entry.TAG_IDX,
+ TAG_NO: entry.TAG_NO || entry.TAG_IDX,
+ TAG_DESC: entry.TAG_DESC || null,
+ status: "From S-EDP",
+ source: "S-EDP"
+ };
+
+ // ATTRIBUTES 추가
+ if (Array.isArray(entry.ATTRIBUTES)) {
+ for (const attr of entry.ATTRIBUTES) {
+ if (attr.ATT_ID && attr.VALUE !== null && attr.VALUE !== undefined) {
+ tagDataForFormEntry[attr.ATT_ID] = attr.VALUE;
}
}
+ }
- newTagsForFormEntry.push(tagDataForFormEntry);
+ newTagsForFormEntry.push(tagDataForFormEntry);
+ processedCount++;
+ }
- processedCount++;
- totalProcessedCount++;
+ totalProcessedCount += processedCount;
+ totalExcludedCount += excludedCount;
- // 주기적으로 진행 상황 보고
- if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) {
- const baseProgress = 60;
- const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount)));
- progressCallback(baseProgress + entryProgress);
- }
- } catch (error: any) {
- console.error(`Error processing tag entry:`, error);
- allErrors.push(error.message || 'Unknown error');
- }
+ if (progressCallback) {
+ progressCallback(baseProgress + progressPerForm * (i + 0.8));
}
- // Step 7: formEntries 업데이트 - TAG_IDX 기준으로 변경
+ // Step 4-5: formEntriesPlant upsert
if (newTagsForFormEntry.length > 0) {
- try {
- // 기존 formEntry 가져오기
- const existingEntry = await db.query.formEntriesPlant.findFirst({
- where: and(
- eq(formEntriesPlant.formCode, formCode),
- eq(formEntriesPlant.projectCode, projectCode),
- eq(formEntriesPlant.packageCode, packageCode)
- )
- });
-
- if (existingEntry && existingEntry.id) {
- // 기존 formEntry가 있는 경우
- let existingData: Array<{
- TAG_IDX?: string; // 추가: TAG_IDX 필드
- TAG_NO?: string;
- TAG_DESC?: string;
- status?: string;
- [key: string]: any;
- }> = [];
-
- if (Array.isArray(existingEntry.data)) {
- existingData = existingEntry.data;
- }
+ const existingEntry = await db.query.formEntriesPlant.findFirst({
+ where: and(
+ eq(formEntriesPlant.formCode, formCode),
+ eq(formEntriesPlant.projectCode, projectCode),
+ eq(formEntriesPlant.packageCode, packageCode)
+ )
+ });
+
+ if (existingEntry) {
+ // 기존 데이터 병합
+ let existingData: Array<Record<string, any>> = [];
+ if (Array.isArray(existingEntry.data)) {
+ existingData = existingEntry.data;
+ }
- // 기존 TAG_IDX들 추출 (변경: TAG_NO → TAG_IDX)
- const existingTagIdxs = new Set(
- existingData
- .map(item => item.TAG_IDX)
- .filter(tagIdx => tagIdx !== undefined && tagIdx !== null)
- );
+ const existingTagIdxs = new Set(
+ existingData.map(item => item.TAG_IDX).filter(Boolean)
+ );
- // 중복되지 않은 새 태그들만 필터링 (변경: TAG_NO → TAG_IDX)
- const newUniqueTagsData = newTagsForFormEntry.filter(
- tagData => !existingTagIdxs.has(tagData.TAG_IDX)
+ // 기존 데이터 업데이트 + 새 데이터 추가
+ const updatedData = existingData.map(existingItem => {
+ const newData = newTagsForFormEntry.find(
+ n => n.TAG_IDX === existingItem.TAG_IDX
);
+ return newData ? { ...existingItem, ...newData } : existingItem;
+ });
- // 기존 태그들의 status와 ATTRIBUTES 업데이트 (변경: TAG_NO → TAG_IDX)
- const updatedExistingData = existingData.map(existingItem => {
- const newTagData = newTagsForFormEntry.find(
- newItem => newItem.TAG_IDX === existingItem.TAG_IDX
- );
-
- if (newTagData) {
- // 기존 태그가 있으면 SEDP 데이터로 업데이트
- return {
- ...existingItem,
- ...newTagData,
- TAG_IDX: existingItem.TAG_IDX // TAG_IDX는 유지
- };
- }
-
- return existingItem;
- });
-
- const finalData = [...updatedExistingData, ...newUniqueTagsData];
+ const newUniqueData = newTagsForFormEntry.filter(
+ n => !existingTagIdxs.has(n.TAG_IDX)
+ );
- await db
- .update(formEntriesPlant)
- .set({
- data: finalData,
- updatedAt: new Date()
- })
- .where(eq(formEntriesPlant.id, existingEntry.id));
+ await db.update(formEntriesPlant)
+ .set({
+ data: [...updatedData, ...newUniqueData],
+ updatedAt: new Date()
+ })
+ .where(eq(formEntriesPlant.id, existingEntry.id));
- console.log(`[IMPORT SEDP] Updated formEntry with ${newUniqueTagsData.length} new tags, updated ${updatedExistingData.length - newUniqueTagsData.length} existing tags for form ${formCode}`);
- } else {
- // formEntry가 없는 경우 새로 생성
- await db.insert(formEntriesPlant).values({
- formCode: formCode,
- projectCode,
- packageCode,
- data: newTagsForFormEntry,
- createdAt: new Date(),
- updatedAt: new Date(),
- });
-
- console.log(`[IMPORT SEDP] Created new formEntry with ${newTagsForFormEntry.length} tags for form ${formCode}`);
- }
+ console.log(`[formEntriesPlant] Updated: ${formCode} (${newUniqueData.length} new, ${updatedData.length} updated)`);
+ } else {
+ // 새로 생성
+ await db.insert(formEntriesPlant).values({
+ formCode,
+ projectCode,
+ packageCode,
+ data: newTagsForFormEntry,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
- // 캐시 무효화
- // revalidateTag(`form-data-${formCode}-${packageId}`);
- } catch (formEntryError) {
- console.error(`[IMPORT SEDP] Error updating formEntry for form ${formCode}:`, formEntryError);
- allErrors.push(`Error updating formEntry for form ${formCode}: ${formEntryError}`);
+ console.log(`[formEntriesPlant] Created: ${formCode} (${newTagsForFormEntry.length} tags)`);
}
}
+ if (progressCallback) {
+ progressCallback(baseProgress + progressPerForm * (i + 1));
+ }
+
} catch (error: any) {
- console.error(`Error processing mapping for formCode ${formCode}:`, error);
- allErrors.push(`Error with formCode ${formCode}: ${error.message || 'Unknown error'}`);
+ console.error(`Error processing form ${formCode}:`, error);
+ allErrors.push(`Form ${formCode}: ${error.message}`);
}
}
- // 모든 매핑 처리 완료 - 진행률 100%
- if (progressCallback) {
- progressCallback(100);
- }
+ if (progressCallback) progressCallback(100);
- // 최종 결과 반환
return {
processedCount: totalProcessedCount,
excludedCount: totalExcludedCount,
totalEntries: totalEntriesCount,
errors: allErrors.length > 0 ? allErrors : undefined
};
+
} catch (error: any) {
console.error("Tag import error:", error);
throw error;
}
-}
-
-/**
- * SEDP API에서 태그 데이터 가져오기
- *
- * @param projectCode 프로젝트 코드
- * @param formCode 양식 코드
- * @returns API 응답 데이터
- */
-async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> {
- try {
- // Get the token
- const apiKey = await getSEDPToken();
-
- // Define the API base URL
- const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
-
- // Make the API call
- const response = await fetch(
- `${SEDP_API_BASE_URL}/Data/GetPubData`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'accept': '*/*',
- 'ApiKey': apiKey,
- 'ProjectNo': projectCode
- },
- body: JSON.stringify({
- ProjectNo: projectCode,
- REG_TYPE_ID: formCode,
- ContainDeleted: false
- })
- }
- );
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
- }
-
- const data = await response.json();
- return data;
- } catch (error: any) {
- console.error('Error calling SEDP API:', error);
- throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`);
- }
} \ No newline at end of file
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
index 9bdd238d..4cdaf90d 100644
--- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -32,6 +32,7 @@ import {
parseSAPDateToString,
findSpecificationByMATNR,
} from './common-mapper-utils';
+import { updateBiddingAmounts } from '@/lib/bidding/service';
// Note: POS 파일은 온디맨드 방식으로 다운로드됩니다.
// 자동 동기화 관련 import는 제거되었습니다.
@@ -255,11 +256,14 @@ export async function mapECCBiddingHeaderToBidding(
// prNumber: 대표 PR의 BANFN 또는 타겟 PR의 ZREQ_FN 값
prNumber = representativeItem?.BANFN || targetItem.ZREQ_FN || null;
}
+
+ // 원입찰번호(originalBiddingNumber)에 생성된 biddingNumber에서 '-'로 split해서 앞부분을 사용
+ const originalBiddingNumber = biddingNumber ? biddingNumber.split('-')[0] : (eccHeader.ANFNR || null);
// 매핑
const mappedData: BiddingData = {
biddingNumber, // 생성된 Bidding 코드
- originalBiddingNumber: eccHeader.ANFNR || null, // 원입찰번호
+ originalBiddingNumber, // 원입찰번호에 생성된 biddingnumber split 결과 사용
revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정)
projectName, // 타겟 PR Item의 PSPID로 찾은 프로젝트 이름
itemName, // 타겟 PR Item 정보로 조회한 자재명/내역
@@ -278,7 +282,6 @@ export async function mapECCBiddingHeaderToBidding(
biddingRegistrationDate: new Date().toISOString(), // 입찰등록일 I/F 시점 등록(1120 이시원 프로 요청)
submissionStartDate: null,
submissionEndDate: null,
- evaluationDate: null,
// 사양설명회
hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리
@@ -293,6 +296,7 @@ export async function mapECCBiddingHeaderToBidding(
// PR 정보
prNumber, // 첫번째 PR의 ZREQ_FN 값
hasPrDocument: false, // PR문서는 POS를 말하는 것으로 보임.
+ plant: eccHeader.WERKS || null, // 플랜트 코드(WERKS)
// 상태 및 설정
status: 'bidding_generated', // 입찰생성 상태
@@ -497,6 +501,20 @@ export async function mapAndSaveECCBiddingData(
};
});
+ // 새로 생성된 Bidding들에 대해 금액 집계 업데이트 (PR 아이템의 금액 정보를 Bidding 헤더에 반영)
+ if (result.insertedBiddings && result.insertedBiddings.length > 0) {
+ debugLog('Bidding 금액 집계 업데이트 시작', { count: result.insertedBiddings.length });
+ await Promise.all(
+ result.insertedBiddings.map(async (bidding) => {
+ try {
+ await updateBiddingAmounts(bidding.id);
+ } catch (err) {
+ debugError(`Bidding ${bidding.biddingNumber} 금액 업데이트 실패`, err);
+ }
+ })
+ );
+ }
+
debugSuccess('ECC Bidding 데이터 일괄 처리 완료', {
processedCount: result.processedCount,
});
diff --git a/lib/soap/ecc/send/chemical-substance-check.ts b/lib/soap/ecc/send/chemical-substance-check.ts
new file mode 100644
index 00000000..b5c4cc25
--- /dev/null
+++ b/lib/soap/ecc/send/chemical-substance-check.ts
@@ -0,0 +1,449 @@
+'use server'
+
+import { sendSoapXml } from "@/lib/soap/sender";
+import type { SoapSendConfig, SoapLogInfo, SoapSendResult } from "@/lib/soap/types";
+
+// ECC 화학물질 조회 엔드포인트 (WSDL에 명시된 인터페이스 사용)
+const ECC_CHEMICAL_SUBSTANCE_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_Q&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MM%2FMMM%5E[P2MM_INTERFACE_NAME]";
+
+// 화학물질 조회 요청 데이터 타입
+export interface ChemicalSubstanceCheckRequest {
+ T_LIST: Array<{
+ BUKRS: string; // Company Code (M, CHAR 4)
+ WERKS: string; // Plant (M, CHAR 4)
+ LIFNR: string; // Vendor's account number (M, CHAR 10)
+ MATNR: string; // Material Number (M, CHAR 18)
+ }>;
+}
+
+// 화학물질 조회 응답 데이터 타입
+export interface ChemicalSubstanceCheckResponse {
+ T_LIST: Array<{
+ QINSPST: string; // Y/N (화학물질 여부)
+ SGTXT: string; // Text (상세 메시지)
+ }>;
+}
+
+// 화학물질 조회 결과 타입 (DB 저장용)
+export interface ChemicalSubstanceResult {
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+ hasChemicalSubstance: boolean;
+ message: string;
+ checkedAt: Date;
+}
+
+// SOAP Body Content 생성 함수
+function createChemicalSubstanceCheckSoapBodyContent(data: ChemicalSubstanceCheckRequest): Record<string, unknown> {
+ return {
+ 'p1:MT_[INTERFACE_NAME]_S': { // 실제 인터페이스명으로 변경 필요
+ 'T_LIST': data.T_LIST
+ }
+ };
+}
+
+// 화학물질 조회 데이터 검증 함수
+function validateChemicalSubstanceCheckData(data: ChemicalSubstanceCheckRequest): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ // T_LIST 배열 검증
+ if (!data.T_LIST || !Array.isArray(data.T_LIST) || data.T_LIST.length === 0) {
+ errors.push('T_LIST는 필수이며 최소 1개 이상의 데이터가 있어야 합니다.');
+ } else {
+ data.T_LIST.forEach((item, index) => {
+ // 필수 필드 검증
+ if (!item.BUKRS || typeof item.BUKRS !== 'string' || item.BUKRS.trim() === '') {
+ errors.push(`T_LIST[${index}].BUKRS은 필수입니다.`);
+ } else if (item.BUKRS.length > 4) {
+ errors.push(`T_LIST[${index}].BUKRS은 4자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.WERKS || typeof item.WERKS !== 'string' || item.WERKS.trim() === '') {
+ errors.push(`T_LIST[${index}].WERKS는 필수입니다.`);
+ } else if (item.WERKS.length > 4) {
+ errors.push(`T_LIST[${index}].WERKS는 4자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.LIFNR || typeof item.LIFNR !== 'string' || item.LIFNR.trim() === '') {
+ errors.push(`T_LIST[${index}].LIFNR은 필수입니다.`);
+ } else if (item.LIFNR.length > 10) {
+ errors.push(`T_LIST[${index}].LIFNR은 10자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.MATNR || typeof item.MATNR !== 'string' || item.MATNR.trim() === '') {
+ errors.push(`T_LIST[${index}].MATNR은 필수입니다.`);
+ } else if (item.MATNR.length > 18) {
+ errors.push(`T_LIST[${index}].MATNR은 18자를 초과할 수 없습니다.`);
+ }
+ });
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+}
+
+// ECC로 화학물질 조회 SOAP XML 전송하는 함수
+async function sendChemicalSubstanceCheckToECC(data: ChemicalSubstanceCheckRequest): Promise<SoapSendResult> {
+ try {
+ // 데이터 검증
+ const validation = validateChemicalSubstanceCheckData(data);
+ if (!validation.isValid) {
+ return {
+ success: false,
+ message: `데이터 검증 실패: ${validation.errors.join(', ')}`
+ };
+ }
+
+ // SOAP Body Content 생성
+ const soapBodyContent = createChemicalSubstanceCheckSoapBodyContent(data);
+
+ // SOAP 전송 설정
+ const config: SoapSendConfig = {
+ endpoint: ECC_CHEMICAL_SUBSTANCE_ENDPOINT,
+ envelope: soapBodyContent,
+ soapAction: 'http://sap.com/xi/WebService/soap1.1',
+ timeout: 30000, // 화학물질 조회는 30초 타임아웃
+ retryCount: 3,
+ retryDelay: 1000,
+ namespace: 'http://shi.samsung.co.kr/P2_MM/MMM', // ECC MM 모듈 네임스페이스
+ prefix: 'p1' // WSDL에서 사용하는 p1 접두사
+ };
+
+ // 로그 정보
+ const logInfo: SoapLogInfo = {
+ direction: 'OUTBOUND',
+ system: 'S-ERP ECC',
+ interface: 'IF_ECC_EVCP_CHEMICAL_SUBSTANCE_CHECK'
+ };
+
+ const materials = data.T_LIST.map(item => `${item.BUKRS}/${item.WERKS}/${item.LIFNR}/${item.MATNR}`).join(', ');
+ console.log(`📤 화학물질 조회 요청 전송 시작 - Materials: ${materials}`);
+ console.log(`🔍 조회 대상 물질 ${data.T_LIST.length}개`);
+
+ // SOAP XML 전송
+ const result = await sendSoapXml(config, logInfo);
+
+ if (result.success) {
+ console.log(`✅ 화학물질 조회 요청 전송 성공 - Materials: ${materials}`);
+ } else {
+ console.error(`❌ 화학물질 조회 요청 전송 실패 - Materials: ${materials}, 오류: ${result.message}`);
+ }
+
+ return result;
+
+ } catch (error) {
+ console.error('❌ 화학물질 조회 전송 중 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// ========================================
+// 메인 화학물질 조회 서버 액션 함수들
+// ========================================
+
+// 단일 화학물질 조회 요청 처리
+export async function checkChemicalSubstance(params: {
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}): Promise<{
+ success: boolean;
+ message: string;
+ hasChemicalSubstance?: boolean;
+ responseData?: string;
+ statusCode?: number;
+ headers?: Record<string, string>;
+ endpoint?: string;
+ requestXml?: string;
+ material?: string;
+}> {
+ try {
+ console.log(`🚀 화학물질 조회 요청 시작 - Material: ${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`);
+
+ const requestData: ChemicalSubstanceCheckRequest = {
+ T_LIST: [{
+ BUKRS: params.bukrs,
+ WERKS: params.werks,
+ LIFNR: params.lifnr,
+ MATNR: params.matnr
+ }]
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(requestData);
+
+ let hasChemicalSubstance: boolean | undefined;
+ let message = result.message;
+
+ if (result.success && result.responseText) {
+ try {
+ // 응답 파싱 로직 (실제 응답 구조에 따라 조정 필요)
+ // QINSPST = 'Y' 이면 화학물질 있음, 'N'이면 없음
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST?.[0]) {
+ const item = responseData.T_LIST[0];
+ hasChemicalSubstance = item.QINSPST === 'Y';
+ message = item.SGTXT || result.message;
+ }
+ } catch (parseError) {
+ console.warn('응답 데이터 파싱 실패:', parseError);
+ }
+ }
+
+ return {
+ success: result.success,
+ message,
+ hasChemicalSubstance,
+ responseData: result.responseText,
+ statusCode: result.statusCode,
+ headers: result.headers,
+ endpoint: result.endpoint,
+ requestXml: result.requestXml,
+ material: `${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`
+ };
+
+ } catch (error) {
+ console.error('❌ 화학물질 조회 요청 처리 실패:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 여러 물질 배치 화학물질 조회 요청 처리
+export async function checkMultipleChemicalSubstances(items: Array<{
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}>): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }>;
+}> {
+ try {
+ console.log(`🚀 배치 화학물질 조회 요청 시작: ${items.length}개`);
+
+ const requestData: ChemicalSubstanceCheckRequest = {
+ T_LIST: items.map(item => ({
+ BUKRS: item.bukrs,
+ WERKS: item.werks,
+ LIFNR: item.lifnr,
+ MATNR: item.matnr
+ }))
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(requestData);
+
+ let results: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }> | undefined;
+
+ if (result.success && result.responseText) {
+ try {
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST && Array.isArray(responseData.T_LIST)) {
+ results = responseData.T_LIST.map((item: any, index: number) => {
+ const originalItem = items[index];
+ const material = `${originalItem.bukrs}/${originalItem.werks}/${originalItem.lifnr}/${originalItem.matnr}`;
+
+ return {
+ material,
+ hasChemicalSubstance: item.QINSPST === 'Y',
+ success: true,
+ message: item.SGTXT
+ };
+ });
+ }
+ } catch (parseError) {
+ console.warn('배치 응답 데이터 파싱 실패:', parseError);
+ // 파싱 실패시 전체 실패로 처리
+ results = items.map(item => ({
+ material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`,
+ success: false,
+ error: '응답 데이터 파싱 실패'
+ }));
+ }
+ } else {
+ // 전송 실패시 모든 항목 실패로 처리
+ results = items.map(item => ({
+ material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`,
+ success: false,
+ error: result.message
+ }));
+ }
+
+ const successCount = results?.filter(r => r.success).length || 0;
+ const failCount = (results?.length || 0) - successCount;
+
+ console.log(`🎉 배치 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: result.success,
+ message: result.success
+ ? `배치 화학물질 조회 성공: ${successCount}개`
+ : `배치 화학물질 조회 실패: ${result.message}`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 배치 화학물질 조회 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 개별 처리 방식의 배치 화학물질 조회 (각각 따로 전송)
+export async function checkMultipleChemicalSubstancesIndividually(items: Array<{
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}>): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }>;
+}> {
+ try {
+ console.log(`🚀 개별 화학물질 조회 요청 시작: ${items.length}개`);
+
+ const results: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }> = [];
+
+ for (const item of items) {
+ try {
+ const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`;
+ console.log(`📤 화학물질 조회 처리 중: ${material}`);
+
+ const checkResult = await checkChemicalSubstance(item);
+
+ results.push({
+ material,
+ hasChemicalSubstance: checkResult.hasChemicalSubstance,
+ success: checkResult.success,
+ error: checkResult.success ? undefined : checkResult.message,
+ message: checkResult.message
+ });
+
+ // 개별 처리간 지연 (시스템 부하 방지)
+ if (items.length > 1) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+
+ } catch (error) {
+ const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`;
+ console.error(`❌ 화학물질 조회 처리 실패: ${material}`, error);
+ results.push({
+ material,
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ console.log(`🎉 개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: failCount === 0,
+ message: `개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 개별 화학물질 조회 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 테스트용 화학물질 조회 함수 (샘플 데이터 포함)
+export async function checkTestChemicalSubstance(): Promise<{
+ success: boolean;
+ message: string;
+ hasChemicalSubstance?: boolean;
+ responseData?: string;
+ testData?: ChemicalSubstanceCheckRequest;
+}> {
+ try {
+ console.log('🧪 테스트용 화학물질 조회 시작');
+
+ // 테스트용 샘플 데이터 생성
+ const testData: ChemicalSubstanceCheckRequest = {
+ T_LIST: [{
+ BUKRS: '1000',
+ WERKS: '1000',
+ LIFNR: 'TEST_VENDOR',
+ MATNR: 'TEST_MATERIAL'
+ }]
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(testData);
+
+ let hasChemicalSubstance: boolean | undefined;
+ let message = result.message;
+
+ if (result.success && result.responseText) {
+ try {
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST?.[0]) {
+ const item = responseData.T_LIST[0];
+ hasChemicalSubstance = item.QINSPST === 'Y';
+ message = item.SGTXT || result.message;
+ }
+ } catch (parseError) {
+ console.warn('테스트 응답 데이터 파싱 실패:', parseError);
+ }
+ }
+
+ return {
+ success: result.success,
+ message,
+ hasChemicalSubstance,
+ responseData: result.responseText,
+ testData
+ };
+
+ } catch (error) {
+ console.error('❌ 테스트 화학물질 조회 실패:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
diff --git a/lib/tags-plant/queries.ts b/lib/tags-plant/queries.ts
index a0d28b1e..c7ad43e0 100644
--- a/lib/tags-plant/queries.ts
+++ b/lib/tags-plant/queries.ts
@@ -5,6 +5,7 @@ import db from "@/db/db"
import { tagsPlant } from "@/db/schema/vendorData"
import { eq, and } from "drizzle-orm"
+import { revalidateTag, unstable_noStore } from "next/cache";
/**
* 모든 태그 가져오기 (클라이언트 렌더링용)
@@ -13,6 +14,7 @@ export async function getAllTagsPlant(
projectCode: string,
packageCode: string
) {
+ unstable_noStore();
try {
const tags = await db
.select()
diff --git a/lib/tags-plant/service.ts b/lib/tags-plant/service.ts
index 9e9d9ebf..27cc207b 100644
--- a/lib/tags-plant/service.ts
+++ b/lib/tags-plant/service.ts
@@ -25,6 +25,14 @@ interface CreatedOrExistingForm {
isNewlyCreated: boolean;
}
+interface FormInfo {
+ formCode: string;
+ formName: string;
+ im: boolean;
+ eng: boolean;
+}
+
+
/**
* 16진수 24자리 고유 식별자 생성
* @returns 24자리 16진수 문자열 (예: "a1b2c3d4e5f6789012345678")
@@ -280,6 +288,7 @@ export async function createTag(
tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가
tagNo: validated.data.tagNo,
class: validated.data.class,
+ subclass: validated.data.subclass,
tagType: validated.data.tagType,
description: validated.data.description ?? null,
})
@@ -1790,13 +1799,11 @@ export async function getIMForms(
return existingForms
}
- // 2. DB에 없으면 SEDP API에서 가져오기
+ // 2. DB에 없으면 두 API 동시 호출
const apiKey = await getSEDPToken()
- // 2-1. GetByToolID로 레지스터 매핑 정보 가져오기
- const mappingResponse = await fetch(
- `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`,
- {
+ const [newRegistersResponse, registersResponse] = await Promise.all([
+ fetch(`${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1808,95 +1815,94 @@ export async function getIMForms(
ProjectNo: projectCode,
TOOL_ID: "eVCP"
})
- }
- )
+ }),
+ fetch(`${SEDP_API_BASE_URL}/Register/Get`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ })
+ ])
- if (!mappingResponse.ok) {
- throw new Error(
- `레지스터 매핑 요청 실패: ${mappingResponse.status} ${mappingResponse.statusText}`
- )
+ if (!newRegistersResponse.ok) {
+ throw new Error(`새 레지스터 요청 실패: ${newRegistersResponse.status}`)
}
- const mappingData = await mappingResponse.json()
- const registers: NewRegister[] = Array.isArray(mappingData)
- ? mappingData
- : [mappingData]
+ if (!registersResponse.ok) {
+ throw new Error(`레지스터 요청 실패: ${registersResponse.status}`)
+ }
- // 2-2. packageCode가 SCOPES에 포함된 레지스터 필터링
- const matchingRegisters = registers.filter(register =>
- register.SCOPES.includes(packageCode)
- )
+ const newRegistersData = await newRegistersResponse.json()
+ const registersData = await registersResponse.json()
- if (matchingRegisters.length === 0) {
- console.log(`패키지 ${packageCode}에 해당하는 레지스터가 없습니다.`)
- return []
+ const newRegisters: NewRegister[] = Array.isArray(newRegistersData)
+ ? newRegistersData
+ : [newRegistersData]
+
+ const registers: RegisterDetail[] = Array.isArray(registersData)
+ ? registersData
+ : [registersData]
+
+ // 3. Register를 Map으로 변환 (TYPE_ID로 빠른 조회)
+ const registerMap = new Map<string, RegisterDetail>()
+ for (const reg of registers) {
+ registerMap.set(reg.TYPE_ID, reg)
}
- // 2-3. 각 레지스터의 상세 정보 가져오기
+ // 4. packageCode가 SCOPES에 포함되고, EP_ID가 "IMEP"인 것만 필터링
const formInfos: FormInfo[] = []
const formsToInsert: typeof formsPlant.$inferInsert[] = []
- for (const register of matchingRegisters) {
- try {
- const detailResponse = await fetch(
- `${SEDP_API_BASE_URL}/Register/GetByID`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'accept': '*/*',
- 'ApiKey': apiKey,
- 'ProjectNo': projectCode
- },
- body: JSON.stringify({
- ProjectNo: projectCode,
- TYPE_ID: register.REG_TYPE_ID,
- ContainDeleted: false
- })
- }
- )
-
- if (!detailResponse.ok) {
- console.error(
- `레지스터 상세 정보 요청 실패 (${register.REG_TYPE_ID}): ${detailResponse.status}`
- )
- continue
- }
-
- const detail: RegisterDetail = await detailResponse.json()
+ for (const newReg of newRegisters) {
+ // packageCode가 SCOPES에 없으면 스킵
+ if (!newReg.SCOPES || !newReg.SCOPES.includes(packageCode)) {
+ continue
+ }
- // DELETED가 true이거나 DESC가 없으면 스킵
- if (detail.DELETED || !detail.DESC) {
- continue
- }
+ const formCode = newReg.REG_TYPE_ID
+ const register = registerMap.get(formCode)
- formInfos.push({
- formCode: detail.TYPE_ID,
- formName: detail.DESC
- })
+ // Register에서 EP_ID가 "IMEP"가 아니면 스킵 (IM 폼만 처리)
+ if (!register || register.EP_ID !== "IMEP") {
+ continue
+ }
- // DB 삽입용 데이터 준비
- formsToInsert.push({
- projectCode: projectCode,
- packageCode: packageCode,
- formCode: detail.TYPE_ID,
- formName: detail.DESC,
- eng: false,
- im: true
- })
- } catch (error) {
- console.error(
- `레지스터 ${register.REG_TYPE_ID} 상세 정보 가져오기 실패:`,
- error
- )
+ // DELETED면 스킵
+ if (register.DELETED) {
continue
}
+
+ const formName = newReg.DESC || register.DESC || formCode
+
+ formInfos.push({
+ formCode,
+ formName
+ })
+
+ formsToInsert.push({
+ projectCode,
+ packageCode,
+ formCode,
+ formName,
+ eng: false,
+ im: true
+ })
}
- // 2-4. DB에 저장
+ // 5. DB에 저장
if (formsToInsert.length > 0) {
- await db.insert(formsPlant).values(formsToInsert).onConflictDoNothing()
- console.log(`${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`)
+ await db.insert(formsPlant)
+ .values(formsToInsert)
+ .onConflictDoNothing()
+
+ console.log(`[getIMForms] ${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`)
}
return formInfos
diff --git a/lib/tags-plant/table/add-tag-dialog.tsx b/lib/tags-plant/table/add-tag-dialog.tsx
index de5d2bf8..1bfb0703 100644
--- a/lib/tags-plant/table/add-tag-dialog.tsx
+++ b/lib/tags-plant/table/add-tag-dialog.tsx
@@ -329,7 +329,7 @@ export function AddTagDialog({ projectCode, packageCode }: AddTagDialogProps) {
const tagData: CreateTagSchema = {
tagType: data.tagType,
class: data.class,
- // subclass: data.subclass, // 서브클래스 정보 추가
+ subclass: data.subclass, // 서브클래스 정보 추가
tagNo: row.tagNo,
description: row.description,
...Object.fromEntries(
diff --git a/lib/tags-plant/table/tag-table-column.tsx b/lib/tags-plant/table/tag-table-column.tsx
index 80c25464..30bdacc3 100644
--- a/lib/tags-plant/table/tag-table-column.tsx
+++ b/lib/tags-plant/table/tag-table-column.tsx
@@ -82,14 +82,27 @@ export function getColumns({
minSize: 150,
size: 240,
},
- {
+ {
accessorKey: "class",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Tag Class" />
+ <DataTableColumnHeaderSimple column={column} title="Class" />
),
cell: ({ row }) => <div>{row.getValue("class")}</div>,
meta: {
- excelHeader: "Tag Class"
+ excelHeader: "Class"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 150,
+ },
+ {
+ accessorKey: "subclass",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Item Class" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("subclass")}</div>,
+ meta: {
+ excelHeader: "Item Class"
},
enableResizing: true,
minSize: 100,
diff --git a/lib/tags-plant/table/tag-table.tsx b/lib/tags-plant/table/tag-table.tsx
index 2fdcd5fc..70bfc4e4 100644
--- a/lib/tags-plant/table/tag-table.tsx
+++ b/lib/tags-plant/table/tag-table.tsx
@@ -78,6 +78,9 @@ export function TagsTable({
const [isLoading, setIsLoading] = React.useState(true)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null)
+
+ console.log(tableData,"tableData")
+
// 선택된 행 관리
const [selectedRowsData, setSelectedRowsData] = React.useState<Tag[]>([])
const [clearSelection, setClearSelection] = React.useState(false)
diff --git a/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx
new file mode 100644
index 00000000..bd53b3cc
--- /dev/null
+++ b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx
@@ -0,0 +1,406 @@
+"use client";
+
+import * as React from "react";
+import { Search, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import {
+ getItemsForVendorMapping,
+ getConnectableVendorsForItem,
+ connectItemWithVendors,
+} from "../service";
+
+type ItemType = "SHIP" | "TOP" | "HULL";
+
+interface ItemData {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ itemType: ItemType;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface VendorData {
+ id: number;
+ vendorName: string;
+ email: string | null;
+ techVendorType: string;
+ status: string;
+}
+
+interface ConnectItemVendorDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConnected?: () => void;
+}
+
+export function ConnectItemVendorDialog({
+ open,
+ onOpenChange,
+ onConnected,
+}: ConnectItemVendorDialogProps) {
+ const [items, setItems] = React.useState<ItemData[]>([]);
+ const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+ const [itemSearch, setItemSearch] = React.useState("");
+ const [selectedItem, setSelectedItem] = React.useState<ItemData | null>(null);
+
+ const [vendors, setVendors] = React.useState<VendorData[]>([]);
+ const [filteredVendors, setFilteredVendors] = React.useState<VendorData[]>([]);
+ const [vendorSearch, setVendorSearch] = React.useState("");
+ const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]);
+
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false);
+ const [isLoadingVendors, setIsLoadingVendors] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // 다이얼로그가 열릴 때 전체 아이템 목록 로드
+ React.useEffect(() => {
+ if (open) {
+ loadItems();
+ }
+ }, [open]);
+
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (!itemSearch) {
+ setFilteredItems(items);
+ return;
+ }
+
+ const lowered = itemSearch.toLowerCase();
+ const filtered = items.filter((item) =>
+ [item.itemCode, item.itemList, item.workType, item.shipTypes, item.subItemList]
+ .filter(Boolean)
+ .some((value) => value?.toLowerCase().includes(lowered))
+ );
+ setFilteredItems(filtered);
+ }, [items, itemSearch]);
+
+ // 벤더 검색 필터링
+ React.useEffect(() => {
+ if (!vendorSearch) {
+ setFilteredVendors(vendors);
+ return;
+ }
+
+ const lowered = vendorSearch.toLowerCase();
+ const filtered = vendors.filter((vendor) =>
+ [vendor.vendorName, vendor.email, vendor.techVendorType, vendor.status]
+ .filter(Boolean)
+ .some((value) => value?.toLowerCase().includes(lowered))
+ );
+ setFilteredVendors(filtered);
+ }, [vendors, vendorSearch]);
+
+ // 특정 아이템 선택 시 연결 가능한 벤더 목록 로드
+ React.useEffect(() => {
+ if (!selectedItem) {
+ setVendors([]);
+ setFilteredVendors([]);
+ setSelectedVendorIds([]);
+ return;
+ }
+ loadVendors(selectedItem);
+ }, [selectedItem]);
+
+ const loadItems = async () => {
+ setIsLoadingItems(true);
+ try {
+ const result = await getItemsForVendorMapping();
+ if (result.error) {
+ throw new Error(result.error);
+ }
+ const validItems = (result.data as ItemData[]).filter((item) => item.itemCode != null);
+ setItems(validItems);
+ } catch (error) {
+ console.error("Failed to load items for mapping:", error);
+ toast.error("아이템 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingItems(false);
+ }
+ };
+
+ const loadVendors = async (item: ItemData) => {
+ setIsLoadingVendors(true);
+ try {
+ const result = await getConnectableVendorsForItem(item.id, item.itemType);
+ if (result.error) {
+ throw new Error(result.error);
+ }
+ setVendors(result.data as VendorData[]);
+ } catch (error) {
+ console.error("Failed to load vendors for item:", error);
+ toast.error("연결 가능한 벤더 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingVendors(false);
+ }
+ };
+
+ const handleItemSelect = (item: ItemData) => {
+ if (!item.itemCode) return;
+ setSelectedItem(item);
+ };
+
+ const handleVendorToggle = (vendorId: number) => {
+ setSelectedVendorIds((prev) =>
+ prev.includes(vendorId)
+ ? prev.filter((id) => id !== vendorId)
+ : [...prev, vendorId]
+ );
+ };
+
+ const handleSubmit = async () => {
+ if (!selectedItem || selectedVendorIds.length === 0) return;
+
+ setIsSubmitting(true);
+ try {
+ const result = await connectItemWithVendors({
+ itemId: selectedItem.id,
+ itemType: selectedItem.itemType,
+ vendorIds: selectedVendorIds,
+ });
+
+ if (!result.success) {
+ throw new Error(result.error || "연결에 실패했습니다.");
+ }
+
+ const successCount = result.successCount || 0;
+ const skippedCount = result.skipped?.length || 0;
+
+ toast.success(
+ `${successCount}개 벤더와 연결되었습니다${
+ skippedCount > 0 ? ` (${skippedCount}개 중복 제외)` : ""
+ }`
+ );
+
+ onConnected?.();
+ handleClose();
+ } catch (error) {
+ console.error("Failed to connect item with vendors:", error);
+ toast.error(error instanceof Error ? error.message : "연결 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ setTimeout(() => {
+ setItemSearch("");
+ setVendorSearch("");
+ setSelectedItem(null);
+ setSelectedVendorIds([]);
+ setItems([]);
+ setFilteredItems([]);
+ setVendors([]);
+ setFilteredVendors([]);
+ }, 200);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>아이템 기준 벤더 연결</DialogTitle>
+ <DialogDescription>
+ 연결할 아이템을 먼저 선택한 후, 해당 아이템과 연결할 벤더를 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
+ {/* 아이템 선택 영역 */}
+ <div className="flex flex-col space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="item-search">아이템 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ id="item-search"
+ placeholder="아이템코드, 아이템리스트, 공종, 선종 검색..."
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ </div>
+
+ {selectedItem && (
+ <div className="space-y-2">
+ <Label>선택된 아이템</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50">
+ <Badge variant="default" className="text-xs">
+ {[selectedItem.itemType, selectedItem.itemCode, selectedItem.shipTypes]
+ .filter(Boolean)
+ .join("-")}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ setSelectedItem(null);
+ }}
+ />
+ </Badge>
+ </div>
+ </div>
+ )}
+
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full">
+ {isLoadingItems ? (
+ <div className="text-center py-4">아이템 로딩 중...</div>
+ ) : filteredItems.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 아이템이 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredItems.map((item) => {
+ if (!item.itemCode) return null;
+ const isSelected = selectedItem?.id === item.id && selectedItem.itemType === item.itemType;
+ const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ""}`;
+ return (
+ <div
+ key={`item-${itemKey}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleItemSelect(item)}
+ >
+ <div className="font-medium">
+ {[`[${item.itemType}]`, item.itemCode, item.shipTypes]
+ .filter(Boolean)
+ .join(" ")}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemList || "-"}
+ </div>
+ <div className="flex flex-wrap gap-2 mt-1 text-xs">
+ <span>공종: {item.workType || "-"}</span>
+ {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+ {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 벤더 선택 영역 */}
+ <div className="flex flex-col space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="vendor-search">벤더 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ id="vendor-search"
+ placeholder="벤더명, 이메일, 벤더타입, 상태로 검색..."
+ value={vendorSearch}
+ onChange={(e) => setVendorSearch(e.target.value)}
+ className="pl-10"
+ disabled={!selectedItem}
+ />
+ </div>
+ </div>
+
+ {selectedVendorIds.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 벤더 ({selectedVendorIds.length}개)</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+ {vendors
+ .filter((vendor) => selectedVendorIds.includes(vendor.id))
+ .map((vendor) => (
+ <Badge key={`selected-vendor-${vendor.id}`} variant="default" className="text-xs">
+ {vendor.vendorName}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleVendorToggle(vendor.id);
+ }}
+ />
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full">
+ {!selectedItem ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 아이템을 먼저 선택해주세요.
+ </div>
+ ) : isLoadingVendors ? (
+ <div className="text-center py-4">벤더 로딩 중...</div>
+ ) : filteredVendors.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 연결 가능한 벤더가 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVendors.map((vendor) => {
+ const isSelected = selectedVendorIds.includes(vendor.id);
+ return (
+ <div
+ key={`vendor-${vendor.id}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleVendorToggle(vendor.id)}
+ >
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.email || "-"}
+ </div>
+ <div className="flex flex-wrap gap-2 mt-1 text-xs">
+ <span>타입: {vendor.techVendorType || "-"}</span>
+ <span>상태: {vendor.status || "-"}</span>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!selectedItem || selectedVendorIds.length === 0 || isSubmitting}
+ >
+ {isSubmitting ? "연결 중..." : `연결 (${selectedVendorIds.length})`}
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx
index 100ef04a..226cddf7 100644
--- a/lib/tech-vendors/possible-items/possible-items-table.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-table.tsx
@@ -37,7 +37,8 @@ import { getTechVendorPossibleItems } from "../../tech-vendor-possible-items/ser
import { deleteTechVendorPossibleItem, getTechVendorDetailById } from "../service"
import type { TechVendorPossibleItem } from "../validations"
import { PossibleItemsTableToolbarActions } from "./possible-items-toolbar-actions"
-import { AddItemDialog } from "./add-item-dialog" // 주석처리
+import { AddItemDialog } from "./add-item-dialog"
+import { ConnectItemVendorDialog } from "./connect-item-vendor-dialog"
interface TechVendorPossibleItemsTableProps {
promises: Promise<
@@ -55,7 +56,8 @@ export function TechVendorPossibleItemsTable({
// Suspense로 받아온 데이터
const [{ data, pageCount }] = React.use(promises)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
- const [showAddDialog, setShowAddDialog] = React.useState(false) // 주석처리
+ const [showAddDialog, setShowAddDialog] = React.useState(false)
+ const [showConnectDialog, setShowConnectDialog] = React.useState(false)
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)
@@ -189,7 +191,8 @@ export function TechVendorPossibleItemsTable({
<PossibleItemsTableToolbarActions
table={table}
vendorId={vendorId}
- onAdd={() => setShowAddDialog(true)} // 주석처리
+ onAdd={() => setShowAddDialog(true)}
+ onConnect={() => setShowConnectDialog(true)}
onRefresh={() => {
// 페이지 새로고침을 위한 콜백
window.location.reload()
@@ -199,13 +202,20 @@ export function TechVendorPossibleItemsTable({
</DataTableAdvancedToolbar>
</DataTable>
- {/* Add Item Dialog */}
+ {/* Add Item Dialog (벤더 기준) */}
<AddItemDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
vendorId={vendorId}
/>
+ {/* Item -> Vendor Connect Dialog (아이템 기준) */}
+ <ConnectItemVendorDialog
+ open={showConnectDialog}
+ onOpenChange={setShowConnectDialog}
+ onConnected={() => window.location.reload()}
+ />
+
{/* Vendor Items Dialog */}
<Dialog open={showItemsDialog} onOpenChange={setShowItemsDialog}>
<DialogContent className="max-w-2xl max-h-[80vh]">
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
index 49a673ff..428f4ce5 100644
--- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
@@ -39,14 +39,16 @@ import {
interface PossibleItemsTableToolbarActionsProps {
table: Table<TechVendorPossibleItem>
vendorId: number
- onAdd: () => void // 주석처리
+ onAdd: () => void
+ onConnect: () => void
onRefresh?: () => void // 데이터 새로고침 콜백
}
export function PossibleItemsTableToolbarActions({
table,
vendorId,
- onAdd, // 주석처리
+ onAdd,
+ onConnect,
onRefresh,
}: PossibleItemsTableToolbarActionsProps) {
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
@@ -362,7 +364,16 @@ export function PossibleItemsTableToolbarActions({
onClick={onAdd}
>
<Plus className="mr-2 h-4 w-4" />
- 아이템 연결
+ 기존 아이템 연결
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onConnect}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 아이템-벤더 연결
</Button>
{selectedRows.length > 0 && (
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index 940e59ce..5290b6a0 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -703,6 +703,267 @@ export interface ItemDropdownOption {
subItemList: string | null;
}
+export interface ItemForVendorMapping {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ itemType: "SHIP" | "TOP" | "HULL";
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface VendorForItemMapping {
+ id: number;
+ vendorName: string;
+ email: string | null;
+ techVendorType: string;
+ status: string;
+}
+
+const itemTypeToVendorType: Record<"SHIP" | "TOP" | "HULL", string> = {
+ SHIP: "조선",
+ TOP: "해양TOP",
+ HULL: "해양HULL",
+};
+
+function parseVendorTypes(value: string | string[] | null) {
+ if (!value) return [] as string[];
+ if (Array.isArray(value)) {
+ return value
+ .map((type) => type.trim())
+ .filter((type) => type.length > 0);
+ }
+ return value
+ .split(",")
+ .map((type) => type.trim())
+ .filter((type) => type.length > 0);
+}
+
+/**
+ * 아이템 기준으로 벤더 매핑 시 사용할 전체 아이템 목록 조회
+ * 벤더에 관계없이 전 타입을 모두 가져온다.
+ */
+export async function getItemsForVendorMapping() {
+ return unstable_cache(
+ async () => {
+ try {
+ const items: ItemForVendorMapping[] = [];
+
+ const shipbuildingItems = await db
+ .select({
+ id: itemShipbuilding.id,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ itemCode: itemShipbuilding.itemCode,
+ itemList: itemShipbuilding.itemList,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding)
+ .orderBy(asc(itemShipbuilding.itemCode));
+
+ items.push(
+ ...shipbuildingItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "SHIP" as const,
+ }))
+ );
+
+ const offshoreTopItems = await db
+ .select({
+ id: itemOffshoreTop.id,
+ createdAt: itemOffshoreTop.createdAt,
+ updatedAt: itemOffshoreTop.updatedAt,
+ itemCode: itemOffshoreTop.itemCode,
+ itemList: itemOffshoreTop.itemList,
+ workType: itemOffshoreTop.workType,
+ subItemList: itemOffshoreTop.subItemList,
+ })
+ .from(itemOffshoreTop)
+ .orderBy(asc(itemOffshoreTop.itemCode));
+
+ items.push(
+ ...offshoreTopItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "TOP" as const,
+ }))
+ );
+
+ const offshoreHullItems = await db
+ .select({
+ id: itemOffshoreHull.id,
+ createdAt: itemOffshoreHull.createdAt,
+ updatedAt: itemOffshoreHull.updatedAt,
+ itemCode: itemOffshoreHull.itemCode,
+ itemList: itemOffshoreHull.itemList,
+ workType: itemOffshoreHull.workType,
+ subItemList: itemOffshoreHull.subItemList,
+ })
+ .from(itemOffshoreHull)
+ .orderBy(asc(itemOffshoreHull.itemCode));
+
+ items.push(
+ ...offshoreHullItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "HULL" as const,
+ }))
+ );
+
+ return { data: items, error: null };
+ } catch (err) {
+ console.error("Failed to fetch items for vendor mapping:", err);
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ };
+ }
+ },
+ ["items-for-vendor-mapping"],
+ {
+ revalidate: 3600,
+ tags: ["items"],
+ }
+ )();
+}
+
+/**
+ * 특정 아이템에 연결 가능한 벤더 목록을 조회
+ * - 이미 연결된 벤더는 제외
+ * - 아이템 타입과 벤더 타입(조선/해양TOP/해양HULL) 매칭
+ */
+export async function getConnectableVendorsForItem(
+ itemId: number,
+ itemType: "SHIP" | "TOP" | "HULL"
+) {
+ unstable_noStore();
+
+ try {
+ // 1) 이미 연결된 벤더 ID 조회
+ const existingVendors = await db
+ .select({ vendorId: techVendorPossibleItems.vendorId })
+ .from(techVendorPossibleItems)
+ .where(
+ itemType === "SHIP"
+ ? eq(techVendorPossibleItems.shipbuildingItemId, itemId)
+ : itemType === "TOP"
+ ? eq(techVendorPossibleItems.offshoreTopItemId, itemId)
+ : eq(techVendorPossibleItems.offshoreHullItemId, itemId)
+ );
+
+ const existingVendorIds = existingVendors.map((row) => row.vendorId);
+
+ // 2) 모든 벤더 조회 후 타입 매칭 + 중복 제외
+ const vendorRows = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ techVendorType: techVendors.techVendorType,
+ status: techVendors.status,
+ })
+ .from(techVendors);
+
+ const targetType = itemTypeToVendorType[itemType];
+
+ const availableVendors: VendorForItemMapping[] = vendorRows
+ .map((vendor) => ({
+ ...vendor,
+ vendorTypes: parseVendorTypes(vendor.techVendorType),
+ }))
+ .filter(
+ (vendor) =>
+ vendor.vendorTypes.includes(targetType) &&
+ !existingVendorIds.includes(vendor.id)
+ )
+ .map(({ vendorTypes, ...rest }) => rest);
+
+ return { data: availableVendors, error: null };
+ } catch (err) {
+ console.error("Failed to fetch connectable vendors:", err);
+ return { data: [], error: "연결 가능한 벤더 조회에 실패했습니다." };
+ }
+}
+
+/**
+ * 선택한 아이템을 여러 벤더와 연결
+ * - 중복 연결은 건너뜀
+ */
+export async function connectItemWithVendors(input: {
+ itemId: number;
+ itemType: "SHIP" | "TOP" | "HULL";
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+
+ if (!input.vendorIds || input.vendorIds.length === 0) {
+ return { success: false, error: "연결할 벤더를 선택해주세요." };
+ }
+
+ try {
+ let successCount = 0;
+ const skipped: number[] = [];
+
+ await db.transaction(async (tx) => {
+ for (const vendorId of input.vendorIds) {
+ const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorId)];
+
+ if (input.itemType === "SHIP") {
+ whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, input.itemId));
+ } else if (input.itemType === "TOP") {
+ whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, input.itemId));
+ } else {
+ whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, input.itemId));
+ }
+
+ const existing = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(...whereConditions),
+ });
+
+ if (existing) {
+ skipped.push(vendorId);
+ continue;
+ }
+
+ const insertData: {
+ vendorId: number;
+ shipbuildingItemId?: number;
+ offshoreTopItemId?: number;
+ offshoreHullItemId?: number;
+ } = { vendorId };
+
+ if (input.itemType === "SHIP") {
+ insertData.shipbuildingItemId = input.itemId;
+ } else if (input.itemType === "TOP") {
+ insertData.offshoreTopItemId = input.itemId;
+ } else {
+ insertData.offshoreHullItemId = input.itemId;
+ }
+
+ await tx.insert(techVendorPossibleItems).values(insertData);
+ successCount += 1;
+ }
+ });
+
+ input.vendorIds.forEach((vendorId) => {
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`);
+ });
+
+ return { success: true, successCount, skipped };
+ } catch (err) {
+ console.error("Failed to connect item with vendors:", err);
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
/**
* Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
* 아이템 코드, 이름, 설명만 간소화해서 반환
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index e6138651..61072d3f 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -94,6 +94,7 @@ export async function selectTechSalesRfqsWithJoin(
// 담당자 및 비고
picCode: techSalesRfqs.picCode,
+ hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors,
remark: techSalesRfqs.remark,
cancelReason: techSalesRfqs.cancelReason,
description: techSalesRfqs.description,
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index cf4d02e2..8ce41cba 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -557,6 +557,7 @@ export async function sendTechSalesRfqToVendors(input: {
email?: string | null;
epId?: string | null;
};
+ hideProjectInfoForVendors?: boolean;
}) {
unstable_noStore();
try {
@@ -573,6 +574,7 @@ export async function sendTechSalesRfqToVendors(input: {
materialCode: true,
description: true,
rfqType: true,
+ hideProjectInfoForVendors: true,
},
with: {
biddingProject: true,
@@ -604,6 +606,23 @@ export async function sendTechSalesRfqToVendors(input: {
}
const isResend = rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT;
+ const effectiveHideProjectInfo =
+ typeof input.hideProjectInfoForVendors === "boolean"
+ ? input.hideProjectInfoForVendors
+ : rfq.hideProjectInfoForVendors ?? false;
+
+ if (
+ typeof input.hideProjectInfoForVendors === "boolean" &&
+ input.hideProjectInfoForVendors !== rfq.hideProjectInfoForVendors
+ ) {
+ await db
+ .update(techSalesRfqs)
+ .set({
+ hideProjectInfoForVendors: input.hideProjectInfoForVendors,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
// 현재 사용자 정보 조회
const sender = await db.query.users.findFirst({
@@ -728,6 +747,9 @@ export async function sendTechSalesRfqToVendors(input: {
const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
const rfqItems = rfqItemsResult.data || [];
+ const projectNameForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.projNm || "";
+ const projectCodeForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.pspid || "";
+
// 이메일 컨텍스트 구성
const emailContext = {
language: language,
@@ -735,8 +757,8 @@ export async function sendTechSalesRfqToVendors(input: {
id: rfq.id,
code: rfq.rfqCode,
title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
- projectCode: rfq.biddingProject?.pspid || '',
- projectName: rfq.biddingProject?.projNm || '',
+ projectCode: projectCodeForVendor,
+ projectName: projectNameForVendor,
description: rfq.remark || '',
dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
materialCode: rfq.materialCode || '',
@@ -990,6 +1012,7 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
projMsrm: quotation.projMsrm,
ptypeNm: quotation.ptypeNm,
} : null,
+ hideProjectInfoForVendors: quotation.hideProjectInfoForVendors ?? false,
},
// 벤더 정보
@@ -1414,6 +1437,7 @@ export async function getVendorQuotations(input: {
dueDate: techSalesRfqs.dueDate,
rfqStatus: techSalesRfqs.status,
description: techSalesRfqs.description,
+ hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors,
// 프로젝트 정보 (직접 조인)
projNm: biddingProjects.projNm,
// 아이템 개수
@@ -3662,7 +3686,8 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number
updatedAt: techSalesVendorQuotationAttachments.updatedAt,
})
.from(techSalesVendorQuotationAttachments)
- .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .where(and(eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, true)))
.orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
return { data: attachments };
@@ -3680,6 +3705,172 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number
}
/**
+ * 기술영업 RFQ 기준 벤더 견적서 요약 목록 조회 (eml 첨부 전용)
+ */
+export async function getTechSalesVendorQuotationsForRfq(rfqId: number) {
+ unstable_noStore();
+ try {
+ const quotations = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ vendorId: techSalesVendorQuotations.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ status: techSalesVendorQuotations.status,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .where(eq(techSalesVendorQuotations.rfqId, rfqId))
+ .orderBy(
+ asc(techVendors.vendorName),
+ asc(techSalesVendorQuotations.id)
+ );
+
+ return { data: quotations, error: null };
+ } catch (error) {
+ console.error("기술영업 RFQ 벤더 견적서 목록 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 조회 (isVendorUpload = false)
+ */
+export async function getTechSalesVendorQuotationEmlAttachments(quotationId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(
+ and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, false)
+ )
+ )
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments, error: null };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 업로드/삭제 처리
+ * - isVendorUpload = false 로 저장 (메일 등 별도 전달 문서 보관용)
+ */
+export async function processTechSalesVendorQuotationEmlAttachments(params: {
+ quotationId: number;
+ newFiles?: { file: File; description?: string }[];
+ deleteAttachmentIds?: number[];
+ uploadedBy: number;
+ revisionId?: number;
+}) {
+ unstable_noStore();
+ const { quotationId, newFiles = [], deleteAttachmentIds = [], uploadedBy, revisionId } = params;
+
+ try {
+ // 견적서 확인
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ columns: { id: true, rfqId: true, quotationVersion: true },
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ const targetRevisionId = revisionId ?? quotation.quotationVersion ?? 0;
+
+ await db.transaction(async (tx) => {
+ // 삭제 처리 (벤더 업로드 파일은 삭제하지 않음)
+ if (deleteAttachmentIds.length > 0) {
+ const deletable = await tx.query.techSalesVendorQuotationAttachments.findMany({
+ where: inArray(techSalesVendorQuotationAttachments.id, deleteAttachmentIds),
+ });
+
+ for (const attachment of deletable) {
+ if (attachment.isVendorUpload) {
+ throw new Error("벤더가 업로드한 파일은 여기서 삭제할 수 없습니다.");
+ }
+
+ await tx
+ .delete(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.id, attachment.id));
+
+ try {
+ deleteFile(attachment.filePath);
+ } catch (fileError) {
+ console.warn("eml 첨부파일 삭제 중 파일 시스템 오류:", fileError);
+ }
+ }
+ }
+
+ // 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, description } of newFiles) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}/eml`,
+ userId: uploadedBy.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId,
+ revisionId: targetRevisionId,
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName || file.name,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ filePath: saveResult.publicPath!,
+ description: description || null,
+ uploadedBy,
+ isVendorUpload: false,
+ });
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${quotationId}`);
+ revalidateTag("quotation-attachments");
+ revalidateTag("techSalesVendorQuotations");
+ if (quotation.rfqId) {
+ revalidateTag(`techSalesRfq-${quotation.rfqId}`);
+ }
+ revalidateTag("techSalesRfqs");
+
+ const refreshed = await getTechSalesVendorQuotationEmlAttachments(quotationId);
+ return { data: refreshed.data, error: refreshed.error };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 처리 오류:", error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
* 특정 리비전의 견적서 첨부파일 조회
*/
export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index fe9befe5..d3a12385 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table";
import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -79,6 +79,7 @@ interface GetColumnsProps<TData> {
onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+ openEmlAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // eml 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
@@ -86,7 +87,8 @@ export function getRfqDetailColumns({
unreadMessages = {},
onQuotationClick,
openQuotationAttachmentsSheet,
- openContactsDialog
+ openContactsDialog,
+ openEmlAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -351,6 +353,42 @@ export function getRfqDetailColumns({
size: 80,
},
{
+ id: "emlAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="eml 첨부" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const handleClick = () => {
+ if (!openEmlAttachmentsSheet) return;
+ openEmlAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="eml 첨부파일 관리"
+ title="eml 첨부파일 관리"
+ >
+ <Mail className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "eml 첨부"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
id: "contacts",
header: "담당자",
cell: ({ row }) => {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 72f03dc3..d8ced6f8 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -19,6 +19,7 @@ import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
import { DeleteVendorDialog } from "./delete-vendors-dialog"
import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import { TechSalesVendorEmlAttachmentsSheet, type VendorEmlAttachment } from "../tech-sales-vendor-eml-attachments-sheet"
import type { QuotationInfo } from "./rfq-detail-column"
import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
@@ -89,6 +90,12 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+ // eml 첨부파일 sheet 상태 관리
+ const [emlAttachmentsSheetOpen, setEmlAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationForEml, setSelectedQuotationForEml] = useState<QuotationInfo | null>(null)
+ const [emlAttachments, setEmlAttachments] = useState<VendorEmlAttachment[]>([])
+ const [isLoadingEmlAttachments, setIsLoadingEmlAttachments] = useState(false)
+
// 벤더 contact 선택 다이얼로그 상태 관리
const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
@@ -250,7 +257,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
contactId: number;
contactEmail: string;
contactName: string;
- }>) => {
+ }>, options?: { hideProjectInfoForVendors?: boolean }) => {
if (!selectedRfqId) {
toast.error("선택된 RFQ가 없습니다.");
return;
@@ -294,6 +301,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
name: session.data.user.name || undefined,
email: session.data.user.email || undefined,
},
+ hideProjectInfoForVendors: options?.hideProjectInfoForVendors,
});
if (result.success) {
@@ -463,6 +471,31 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
}, [])
+ // eml 첨부파일 sheet 열기 핸들러
+ const handleOpenEmlAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingEmlAttachments(true)
+ setSelectedQuotationForEml(quotationInfo)
+ setEmlAttachmentsSheetOpen(true)
+
+ const { getTechSalesVendorQuotationEmlAttachments } = await import("@/lib/techsales-rfq/service")
+ const result = await getTechSalesVendorQuotationEmlAttachments(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ setEmlAttachments([])
+ } else {
+ setEmlAttachments(result.data || [])
+ }
+ } catch (error) {
+ console.error("eml 첨부파일 조회 오류:", error)
+ toast.error("eml 첨부파일을 불러오는 중 오류가 발생했습니다.")
+ setEmlAttachments([])
+ } finally {
+ setIsLoadingEmlAttachments(false)
+ }
+ }, [])
+
// 담당자 조회 다이얼로그 열기 함수
const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
setSelectedQuotationForContacts({ id: quotationId, vendorName })
@@ -554,8 +587,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
unreadMessages,
onQuotationClick: handleOpenHistoryDialog,
openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
- openContactsDialog: handleOpenContactsDialog
- }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+ openContactsDialog: handleOpenContactsDialog,
+ openEmlAttachmentsSheet: handleOpenEmlAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog, handleOpenEmlAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -928,6 +962,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
isLoading={isLoadingAttachments}
/>
+ {/* eml 첨부파일 Sheet */}
+ <TechSalesVendorEmlAttachmentsSheet
+ open={emlAttachmentsSheetOpen}
+ onOpenChange={setEmlAttachmentsSheetOpen}
+ quotation={selectedQuotationForEml}
+ attachments={emlAttachments}
+ isLoading={isLoadingEmlAttachments}
+ onAttachmentsChange={setEmlAttachments}
+ />
+
{/* 벤더 contact 선택 다이얼로그 */}
<VendorContactSelectionDialog
open={contactSelectionDialogOpen}
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
index d83394bb..8daa9be7 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
@@ -49,7 +49,10 @@ interface VendorContactSelectionDialogProps {
onOpenChange: (open: boolean) => void
vendorIds: number[]
rfqId?: number // RFQ ID 추가
- onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
+ onSendRfq: (
+ selectedContacts: SelectedContact[],
+ options: { hideProjectInfoForVendors: boolean }
+ ) => Promise<void>
}
export function VendorContactSelectionDialog({
@@ -63,6 +66,7 @@ export function VendorContactSelectionDialog({
const [selectedContacts, setSelectedContacts] = useState<SelectedContact[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSending, setIsSending] = useState(false)
+ const [hideProjectInfoForVendors, setHideProjectInfoForVendors] = useState(false)
// 벤더 contact 정보 조회
useEffect(() => {
@@ -77,6 +81,7 @@ export function VendorContactSelectionDialog({
setVendorsWithContacts({})
setSelectedContacts([])
setIsLoading(false)
+ setHideProjectInfoForVendors(false)
}
}, [open])
@@ -177,7 +182,7 @@ export function VendorContactSelectionDialog({
try {
setIsSending(true)
- await onSendRfq(selectedContacts)
+ await onSendRfq(selectedContacts, { hideProjectInfoForVendors })
onOpenChange(false)
} catch (error) {
console.error("RFQ 발송 오류:", error)
@@ -328,8 +333,17 @@ export function VendorContactSelectionDialog({
<DialogFooter>
<div className="flex items-center justify-between w-full">
- <div className="text-sm text-muted-foreground">
- 총 {selectedContacts.length}명의 연락처가 선택됨
+ <div className="flex flex-col gap-2">
+ <div className="text-sm text-muted-foreground">
+ 총 {selectedContacts.length}명의 연락처가 선택됨
+ </div>
+ <label className="flex items-center gap-2 text-sm">
+ <Checkbox
+ checked={hideProjectInfoForVendors}
+ onCheckedChange={(checked) => setHideProjectInfoForVendors(!!checked)}
+ />
+ 벤더 화면에서 프로젝트명/선주명을 숨기기
+ </label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
diff --git a/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
new file mode 100644
index 00000000..2b6f6753
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
@@ -0,0 +1,348 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+import { Badge } from "@/components/ui/badge"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form"
+import { toast } from "sonner"
+import { Download, Loader, Trash2, X } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { useSession } from "next-auth/react"
+import { useForm } from "react-hook-form"
+import { formatDate } from "@/lib/utils"
+import {
+ getTechSalesVendorQuotationEmlAttachments,
+ processTechSalesVendorQuotationEmlAttachments,
+} from "@/lib/techsales-rfq/service"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+export interface VendorEmlAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number | null
+ vendorId: number | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesVendorEmlAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: VendorEmlAttachment[]
+ onAttachmentsChange?: (attachments: VendorEmlAttachment[]) => void
+ isLoading?: boolean
+}
+
+export function TechSalesVendorEmlAttachmentsSheet({
+ quotation,
+ attachments,
+ onAttachmentsChange,
+ isLoading = false,
+ ...props
+}: TechSalesVendorEmlAttachmentsSheetProps) {
+ const session = useSession()
+ const [isPending, setIsPending] = React.useState(false)
+ const [existing, setExisting] = React.useState<VendorEmlAttachment[]>(attachments)
+ const [newUploads, setNewUploads] = React.useState<File[]>([])
+ const [deleteIds, setDeleteIds] = React.useState<number[]>([])
+
+ const form = useForm({
+ defaultValues: {
+ dummy: true,
+ },
+ })
+
+ // sync when parent changes
+ React.useEffect(() => {
+ setExisting(attachments)
+ setNewUploads([])
+ setDeleteIds([])
+ }, [attachments])
+
+ const handleDownloadClick = React.useCallback(async (attachment: VendorEmlAttachment) => {
+ try {
+ const { downloadFile } = await import("@/lib/file-download")
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error("다운로드 오류:", error)
+ toast.error(error)
+ },
+ })
+ } catch (error) {
+ console.error("다운로드 오류:", error)
+ toast.error("파일 다운로드 중 오류가 발생했습니다.")
+ }
+ }, [])
+
+ const handleDropAccepted = React.useCallback((accepted: File[]) => {
+ setNewUploads((prev) => [...prev, ...accepted])
+ }, [])
+
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 형식입니다.")
+ }, [])
+
+ const handleRemoveExisting = React.useCallback((id: number) => {
+ setDeleteIds((prev) => (prev.includes(id) ? prev : [...prev, id]))
+ setExisting((prev) => prev.filter((att) => att.id !== id))
+ }, [])
+
+ const handleRemoveNewUpload = React.useCallback((index: number) => {
+ setNewUploads((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const handleSubmit = async () => {
+ if (!quotation) {
+ toast.error("견적 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const userId = Number(session.data?.user.id || 0)
+ if (!userId) {
+ toast.error("로그인 정보를 확인해주세요.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ const result = await processTechSalesVendorQuotationEmlAttachments({
+ quotationId: quotation.id,
+ newFiles: newUploads.map((file) => ({ file })),
+ deleteAttachmentIds: deleteIds,
+ uploadedBy: userId,
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ const refreshed =
+ result.data ||
+ (await getTechSalesVendorQuotationEmlAttachments(quotation.id)).data ||
+ []
+
+ setExisting(refreshed)
+ setNewUploads([])
+ setDeleteIds([])
+ onAttachmentsChange?.(refreshed)
+ toast.success("Eml 첨부파일이 저장되었습니다.")
+ props.onOpenChange?.(false)
+ } catch (error) {
+ console.error("eml 첨부파일 저장 오류:", error)
+ toast.error("eml 첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const totalNewSize = newUploads.reduce((acc, f) => acc + f.size, 0)
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>eml 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ {quotation?.vendorName && <div>벤더: {quotation.vendorName}</div>}
+ {quotation?.rfqCode && <div>RFQ: {quotation.rfqCode}</div>}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={(e) => e.preventDefault()} className="flex flex-1 flex-col gap-6">
+ {/* 기존 첨부 */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existing.length}개)
+ </h6>
+ {isLoading ? (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader className="h-4 w-4 animate-spin" />
+ 로딩 중...
+ </div>
+ ) : existing.length === 0 ? (
+ <div className="text-sm text-muted-foreground">첨부파일이 없습니다.</div>
+ ) : (
+ existing.map((att) => (
+ <div
+ key={att.id}
+ className="flex items-start justify-between p-3 border rounded-md gap-3"
+ >
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {att.originalFileName || att.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ rev {att.revisionId}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {prettyBytes(att.fileSize)} • {formatDate(att.createdAt, "KR")}
+ </p>
+ {att.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {att.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ type="button"
+ onClick={() => handleDownloadClick(att)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ type="button"
+ onClick={() => handleRemoveExisting(att.id)}
+ title="삭제"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+
+ {/* 새 업로드 */}
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="dummy"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 eml 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>복수 파일 업로드 가능</FormDescription>
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {newUploads.length > 0 && (
+ <div className="grid gap-3">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploads.length}개)
+ </h6>
+ <span className="text-xs text-muted-foreground">
+ 총 용량 {prettyBytes(totalNewSize)}
+ </span>
+ </div>
+ <FileList>
+ {newUploads.map((file, idx) => (
+ <FileListItem key={`${file.name}-${idx}`}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>{prettyBytes(file.size)}</FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => handleRemoveNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 닫기
+ </Button>
+ </SheetClose>
+ <Button
+ type="button"
+ onClick={handleSubmit}
+ disabled={isPending || (!newUploads.length && deleteIds.length === 0)}
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
index 8a45f529..31e87330 100644
--- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -16,6 +16,7 @@ interface ProjectInfoTabProps {
dueDate: Date | null
status: string | null
remark: string | null
+ hideProjectInfoForVendors?: boolean
biddingProject?: {
id: number
pspid: string | null
@@ -110,7 +111,9 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
프로젝트 기본 정보
- <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ <Badge variant="outline">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.pspid || "N/A")}
+ </Badge>
</CardTitle>
<CardDescription>
연결된 프로젝트의 기본 정보
@@ -120,11 +123,15 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ <div className="text-sm">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.pspid || "N/A")}
+ </div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ <div className="text-sm">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.projNm || "N/A")}
+ </div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index aabe7a64..97f21be2 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -27,6 +27,7 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
materialCode?: string;
dueDate?: Date;
rfqStatus?: string;
+ hideProjectInfoForVendors?: boolean;
// 아이템 정보
itemName?: string;
@@ -258,17 +259,19 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog, open
),
cell: ({ row }) => {
const projNm = row.getValue("projNm") as string;
+ const hideProjectInfo = row.original.hideProjectInfoForVendors === true;
+ const displayValue = hideProjectInfo ? "비공개" : projNm || "N/A";
return (
<div className="min-w-48 max-w-64">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block text-sm">
- {projNm || "N/A"}
+ {displayValue}
</span>
</TooltipTrigger>
<TooltipContent>
- <p className="max-w-xs">{projNm || "N/A"}</p>
+ <p className="max-w-xs">{displayValue}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index d4e0ff33..b6cf6d7a 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -44,7 +44,8 @@ import {
updateDocument,
deleteDocuments,
updateStage,
- getDocumentClassOptionsByContract
+ getDocumentClassOptionsByContract,
+ checkDuplicateDocuments
} from "./document-stages-service"
import { type Row } from "@tanstack/react-table"
@@ -127,6 +128,14 @@ export function AddDocumentDialog({
const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([])
const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({})
+ // Duplicate check states
+ const [duplicateWarning, setDuplicateWarning] = React.useState<{
+ isDuplicate: boolean
+ type?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ message?: string
+ }>({ isDuplicate: false })
+ const [isCheckingDuplicate, setIsCheckingDuplicate] = React.useState(false)
+
// Initialize react-hook-form
const form = useForm<DocumentFormValues>({
resolver: zodResolver(documentFormSchema),
@@ -167,6 +176,7 @@ export function AddDocumentDialog({
setShiComboBoxOptions({})
setCpyComboBoxOptions({})
setDocumentClassOptions([])
+ setDuplicateWarning({ isDuplicate: false })
}
}, [open])
@@ -359,6 +369,59 @@ export function AddDocumentDialog({
return preview && preview !== '' && !preview.includes('[value]')
}
+ // Real-time duplicate check with debounce
+ const checkDuplicateDebounced = React.useMemo(() => {
+ let timeoutId: NodeJS.Timeout | null = null
+
+ return (shiDocNo: string, cpyDocNo: string) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+
+ timeoutId = setTimeout(async () => {
+ // Skip if both are empty or incomplete
+ if ((!shiDocNo || shiDocNo.includes('[value]')) &&
+ (!cpyDocNo || cpyDocNo.includes('[value]'))) {
+ setDuplicateWarning({ isDuplicate: false })
+ return
+ }
+
+ setIsCheckingDuplicate(true)
+ try {
+ const result = await checkDuplicateDocuments(
+ contractId,
+ shiDocNo && !shiDocNo.includes('[value]') ? shiDocNo : undefined,
+ cpyDocNo && !cpyDocNo.includes('[value]') ? cpyDocNo : undefined
+ )
+
+ if (result.isDuplicate) {
+ setDuplicateWarning({
+ isDuplicate: true,
+ type: result.duplicateType,
+ message: result.message
+ })
+ } else {
+ setDuplicateWarning({ isDuplicate: false })
+ }
+ } catch (error) {
+ console.error('Duplicate check error:', error)
+ } finally {
+ setIsCheckingDuplicate(false)
+ }
+ }, 500) // 500ms debounce
+ }
+ }, [contractId])
+
+ // Trigger duplicate check when document numbers change
+ React.useEffect(() => {
+ const shiPreview = generateShiPreview()
+ const cpyPreview = generateCpyPreview()
+
+ if (shiPreview || cpyPreview) {
+ checkDuplicateDebounced(shiPreview, cpyPreview)
+ }
+ }, [shiFieldValues, cpyFieldValues])
+
const onSubmit = async (data: DocumentFormValues) => {
// Validate that at least one document number is configured and complete
if (shiType && !isShiComplete()) {
@@ -520,6 +583,24 @@ export function AddDocumentDialog({
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
+ {/* Duplicate Warning Alert */}
+ {duplicateWarning.isDuplicate && (
+ <Alert variant="destructive" className="border-red-300 bg-red-50 dark:bg-red-950/50">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ {duplicateWarning.message}
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* Checking Duplicate Indicator */}
+ {isCheckingDuplicate && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ Checking for duplicates...
+ </div>
+ )}
+
{/* SHI Document Number Card */}
{shiType && (
<Card className="border-blue-200 dark:border-blue-800">
@@ -719,7 +800,9 @@ export function AddDocumentDialog({
form.formState.isSubmitting ||
!hasAvailableTypes ||
(shiType && !isShiComplete()) ||
- (cpyType && !isCpyComplete())
+ (cpyType && !isCpyComplete()) ||
+ duplicateWarning.isDuplicate ||
+ isCheckingDuplicate
}
>
{form.formState.isSubmitting ? (
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index ed4099b3..cf19eb41 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -878,6 +878,127 @@ interface CreateDocumentData {
vendorDocNumber?: string
}
+// ═══════════════════════════════════════════════════════════════════════════════
+// 문서번호 중복 체크 함수 (SHI_DOC_NO / OWN_DOC_NO 각각 중복 방지)
+// ═══════════════════════════════════════════════════════════════════════════════
+interface CheckDuplicateResult {
+ isDuplicate: boolean
+ duplicateType?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ existingDocNumbers?: {
+ shiDocNo?: string
+ ownDocNo?: string
+ }
+ message?: string
+}
+
+/**
+ * 프로젝트 내에서 SHI_DOC_NO (docNumber)와 OWN_DOC_NO (vendorDocNumber) 중복 체크
+ * @param contractId 계약 ID (프로젝트 ID를 가져오기 위함)
+ * @param shiDocNo SHI 문서번호 (docNumber)
+ * @param ownDocNo CPY 문서번호 (vendorDocNumber)
+ * @param excludeDocumentId 수정 시 제외할 문서 ID (선택)
+ */
+export async function checkDuplicateDocuments(
+ contractId: number,
+ shiDocNo?: string,
+ ownDocNo?: string,
+ excludeDocumentId?: number
+): Promise<CheckDuplicateResult> {
+ try {
+ // 1. 계약에서 프로젝트 ID 가져오기
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ columns: { projectId: true },
+ })
+
+ if (!contract) {
+ return { isDuplicate: false, message: "유효하지 않은 계약입니다." }
+ }
+
+ const { projectId } = contract
+ let shiDuplicate = false
+ let ownDuplicate = false
+ const existingDocNumbers: { shiDocNo?: string; ownDocNo?: string } = {}
+
+ // 2. SHI_DOC_NO 중복 체크 (docNumber)
+ if (shiDocNo && shiDocNo.trim() !== '') {
+ const shiConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.docNumber, shiDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ shiConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingShiDoc = await db
+ .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber })
+ .from(stageDocuments)
+ .where(and(...shiConditions))
+ .limit(1)
+
+ if (existingShiDoc.length > 0) {
+ shiDuplicate = true
+ existingDocNumbers.shiDocNo = existingShiDoc[0].docNumber
+ }
+ }
+
+ // 3. OWN_DOC_NO 중복 체크 (vendorDocNumber)
+ if (ownDocNo && ownDocNo.trim() !== '') {
+ const ownConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.vendorDocNumber, ownDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ ownConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingOwnDoc = await db
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(and(...ownConditions))
+ .limit(1)
+
+ if (existingOwnDoc.length > 0) {
+ ownDuplicate = true
+ existingDocNumbers.ownDocNo = existingOwnDoc[0].vendorDocNumber || undefined
+ }
+ }
+
+ // 4. 결과 반환
+ if (shiDuplicate && ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'BOTH',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' and CPY Document Number '${ownDocNo}' already exist in this project.`
+ }
+ } else if (shiDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'SHI_DOC_NO',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' already exists in this project.`
+ }
+ } else if (ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'OWN_DOC_NO',
+ existingDocNumbers,
+ message: `CPY Document Number '${ownDocNo}' already exists in this project.`
+ }
+ }
+
+ return { isDuplicate: false }
+ } catch (error) {
+ console.error("중복 체크 실패:", error)
+ return { isDuplicate: false, message: "중복 체크 중 오류가 발생했습니다." }
+ }
+}
+
// 문서 생성
export async function createDocument(data: CreateDocumentData) {
try {
@@ -907,6 +1028,20 @@ export async function createDocument(data: CreateDocumentData) {
return { success: false, error: configsResult.error }
}
+ /* ──────────────────────────────── 2. 중복 체크 (SHI_DOC_NO & OWN_DOC_NO) ─────────────────────────────── */
+ const duplicateCheck = await checkDuplicateDocuments(
+ data.contractId,
+ data.docNumber,
+ data.vendorDocNumber
+ )
+
+ if (duplicateCheck.isDuplicate) {
+ return {
+ success: false,
+ error: duplicateCheck.message || "Document number already exists in this project.",
+ duplicateType: duplicateCheck.duplicateType,
+ }
+ }
/* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
const insertData = {
@@ -1403,7 +1538,7 @@ export async function uploadImportData(data: UploadData) {
try {
// 개별 트랜잭션으로 각 문서 처리
const result = await db.transaction(async (tx) => {
- // 먼저 문서가 이미 존재하는지 확인
+ // 먼저 SHI_DOC_NO (docNumber)가 이미 존재하는지 확인
const [existingDoc] = await tx
.select({ id: stageDocuments.id })
.from(stageDocuments)
@@ -1417,7 +1552,26 @@ export async function uploadImportData(data: UploadData) {
.limit(1)
if (existingDoc) {
- throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`)
+ throw new Error(`SHI Document Number "${doc.docNumber}" already exists in this project`)
+ }
+
+ // OWN_DOC_NO (vendorDocNumber) 중복 체크
+ if (doc.vendorDocNumber && doc.vendorDocNumber.trim() !== '') {
+ const [existingVendorDoc] = await tx
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, contract.projectId),
+ eq(stageDocuments.vendorDocNumber, doc.vendorDocNumber.trim()),
+ eq(stageDocuments.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ if (existingVendorDoc) {
+ throw new Error(`CPY Document Number "${doc.vendorDocNumber}" already exists in this project`)
+ }
}
// 3-1. 문서 생성
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index cf37ad06..5e53d0dd 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -339,6 +339,9 @@ export async function updateVendorInvestigationResultAction(formData: FormData)
processedEntries.investigationNotes = textEntries.investigationNotes
}
+ // attachments는 별도로 업로드되므로 빈 배열로 설정
+ processedEntries.attachments = []
+
// 3) Zod로 파싱/검증
const parsed = updateVendorInvestigationResultSchema.parse(processedEntries)
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
index 84361ef9..29fb46cb 100644
--- a/lib/vendor-investigation/validations.ts
+++ b/lib/vendor-investigation/validations.ts
@@ -140,7 +140,8 @@ export const updateVendorInvestigationResultSchema = z.object({
.max(100, "평가 점수는 100점 이하여야 합니다."),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
- attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."),
+ // attachments는 별도의 API로 업로드되므로 이 스키마에서는 optional
+ attachments: z.array(z.any()).optional(),
}).superRefine((data, ctx) => {
// 날짜 검증: 실제 실사일이 실사의뢰일보다 과거가 되지 않도록 검증
if (data.requestedAt && data.completedAt) {
@@ -198,7 +199,7 @@ export const updateVendorInvestigationSchema = z.object({
.optional(),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
- attachments: z.any().optional(), // File 업로드를 위한 필드
+ attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."), // File 업로드 필수
}).superRefine((data, ctx) => {
// 날짜 검증: 실사의뢰일(requestedAt)이 있는 경우 다른 날짜들이 실사의뢰일보다 과거가 되지 않도록 검증
if (data.requestedAt) {
diff --git a/lib/vendors/items-table/item-action-dialog.tsx b/lib/vendors/items-table/item-action-dialog.tsx
index 19df27f8..6bbcc436 100644
--- a/lib/vendors/items-table/item-action-dialog.tsx
+++ b/lib/vendors/items-table/item-action-dialog.tsx
@@ -1,248 +1,289 @@
-// components/vendor-items/item-actions-dialogs.tsx
"use client"
import * as React from "react"
-import type { DataTableRowAction } from "@/types/table"
-import { VendorItemsView } from "@/db/schema/vendors"
-import { toast } from "sonner"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import { Button } from "@/components/ui/button"
-import { Label } from "@/components/ui/label"
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { cn } from "@/lib/utils"
-import { updateVendorItem, deleteVendorItem, getItemsForVendor } from "../service"
+import {
+ createVendorItemSchema,
+ type CreateVendorItemSchema,
+} from "../validations"
-interface ItemActionsDialogsProps {
+import { createVendorItem, getItemsForVendor, ItemDropdownOption } from "../service"
+
+interface AddItemDialogProps {
vendorId: number
- rowAction: DataTableRowAction<VendorItemsView> | null
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>>
}
-export function ItemActionsDialogs({
- vendorId,
- rowAction,
- setRowAction,
-}: ItemActionsDialogsProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const [availableMaterials, setAvailableMaterials] = React.useState<any[]>([])
- const [selectedItemCode, setSelectedItemCode] = React.useState<string>("")
-
- // 사용 가능한 재료 목록 로드
- React.useEffect(() => {
- if (rowAction?.type === "update") {
- getItemsForVendor(vendorId).then((result) => {
- if (result.data) {
- setAvailableMaterials(result.data)
- }
- })
- }
- }, [rowAction, vendorId])
-
- // Edit Dialog
- const EditDialog = () => {
- if (!rowAction || rowAction.type !== "update") return null
+export function AddItemDialog({ vendorId }: AddItemDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [commandOpen, setCommandOpen] = React.useState(false)
+ const [items, setItems] = React.useState<ItemDropdownOption[]>([])
+ const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [searchTerm, setSearchTerm] = React.useState("")
+
+ // 선택된 아이템의 정보를 보여주기 위한 상태
+ const [selectedItem, setSelectedItem] = React.useState<{
+ itemName: string;
+ description: string;
+ } | null>(null)
- const item = rowAction.row.original
+ // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만
+ const form = useForm<CreateVendorItemSchema>({
+ resolver: zodResolver(createVendorItemSchema),
+ defaultValues: {
+ vendorId,
+ itemCode: "",
+ },
+ })
- const handleSubmit = () => {
- if (!selectedItemCode) {
- toast.error("Please select a new item")
- return
- }
+ console.log(vendorId)
- if (!item.itemCode) {
- toast.error("Invalid item code")
- return
+ // 아이템 목록 가져오기 (한 번만 호출)
+ const fetchItems = React.useCallback(async () => {
+ if (items.length > 0) return // 이미 로드된 경우 스킵
+
+ setIsLoading(true)
+ try {
+ const result = await getItemsForVendor(vendorId)
+ if (result.data) {
+ setItems(result.data)
+ setFilteredItems(result.data)
}
+ } catch (error) {
+ console.error("Failed to fetch items:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [items.length])
- startUpdateTransition(async () => {
- const result = await updateVendorItem(vendorId, item.itemCode, selectedItemCode)
-
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("Item updated successfully")
- setRowAction(null)
- }
- })
+ // 팝오버 열릴 때 아이템 목록 로드
+ React.useEffect(() => {
+ if (commandOpen) {
+ fetchItems()
}
+ }, [commandOpen, fetchItems])
- return (
- <Dialog
- open={true}
- onOpenChange={(open) => !open && setRowAction(null)}
- >
- <DialogContent className="sm:max-w-[425px]">
- <DialogHeader>
- <DialogTitle>Change Item</DialogTitle>
- <DialogDescription>
- Select a new item to replace "{item.itemName}" (Code: {item.itemCode || 'N/A'}).
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- <div className="space-y-2">
- <Label>Current Item</Label>
- <div className="p-2 bg-muted rounded-md">
- <div className="font-medium">{item.itemName}</div>
- <div className="text-sm text-muted-foreground">Code: {item.itemCode || 'N/A'}</div>
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="newItem">New Item</Label>
- <Select value={selectedItemCode} onValueChange={setSelectedItemCode}>
- <SelectTrigger>
- <SelectValue placeholder="Select a new item" />
- </SelectTrigger>
- <SelectContent>
- {availableMaterials.map((material) => (
- <SelectItem key={material.itemCode} value={material.itemCode}>
- <div>
- <div className="font-medium">{material.itemName}</div>
- <div className="text-sm text-muted-foreground">Code: {material.itemCode}</div>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => setRowAction(null)}
- disabled={isUpdatePending}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isUpdatePending || !selectedItemCode}
- >
- {isUpdatePending ? "Updating..." : "Update Item"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
+ // 클라이언트 사이드 필터링
+ React.useEffect(() => {
+ if (!items.length) return
+
+ if (!searchTerm.trim()) {
+ setFilteredItems(items)
+ return
+ }
+
+ const lowerSearch = searchTerm.toLowerCase()
+ const filtered = items.filter(item =>
+ item.itemCode.toLowerCase().includes(lowerSearch) ||
+ item.itemName.toLowerCase().includes(lowerSearch) ||
+ (item.description && item.description.toLowerCase().includes(lowerSearch))
)
- }
-
- // Delete Dialog
- const DeleteDialog = () => {
- if (!rowAction || rowAction.type !== "delete") return null
-
- const item = rowAction.row.original
+
+ setFilteredItems(filtered)
+ }, [searchTerm, items])
- const handleDelete = () => {
- if (!item.itemCode) {
- toast.error("Invalid item code")
- return
- }
+ // 선택된 아이템 데이터로 폼 업데이트
+ const handleSelectItem = (item: ItemDropdownOption) => {
+ // 폼에는 itemCode만 설정
+ form.setValue("itemCode", item.itemCode)
+
+ // 나머지 정보는 표시용 상태에 저장
+ setSelectedItem({
+ itemName: item.itemName,
+ description: item.description || "",
+ })
+
+ setCommandOpen(false)
+ }
- startDeleteTransition(async () => {
- const result = await deleteVendorItem(vendorId, item.itemCode)
-
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("Item deleted successfully")
- setRowAction(null)
- }
- })
+ // 폼 제출 - itemCode만 서버로 전송
+ async function onSubmit(data: CreateVendorItemSchema) {
+ // 서버에는 vendorId와 itemCode만 전송됨
+ const result = await createVendorItem(data)
+ console.log(result)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
}
-
- return (
- <AlertDialog
- open={true}
- onOpenChange={(open) => !open && setRowAction(null)}
- >
- <AlertDialogContent>
- return (
- <AlertDialog
- open={true}
- onOpenChange={(open) => !open && setRowAction(null)}
- >
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>Are you sure?</AlertDialogTitle>
- <AlertDialogDescription>
- This will permanently delete the item "{item.itemName}" (Code: {item.itemCode || 'N/A'}).
- This action cannot be undone.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isDeletePending}>
- Cancel
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDelete}
- disabled={isDeletePending}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeletePending ? "Deleting..." : "Delete"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ setOpen(false)
}
- return (
- <>
- <EditDialog />
- <DeleteDialog />
- </>
- )
-}
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isDeletePending}>
- Cancel
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDelete}
- disabled={isDeletePending}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeletePending ? "Deleting..." : "Delete"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )
+ // 모달 열림/닫힘 핸들
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ // 닫힐 때 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ }
+ setOpen(nextOpen)
}
+ // 현재 선택된 아이템 코드
+ const selectedItemCode = form.watch("itemCode")
+
+ // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기
+ const displayItemCode = selectedItemCode || "아이템 선택..."
+ const displayItemName = selectedItem?.itemName || ""
+
return (
- <>
- <EditDialog />
- <DeleteDialog />
- </>
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달 열기 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Item
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Create New Item</DialogTitle>
+ <DialogDescription>
+ 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form + react-hook-form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden">
+ <div className="space-y-4 py-4 flex-1 overflow-y-auto">
+
+ {/* 아이템 선택 */}
+ <div>
+ <FormLabel className="text-sm font-medium">아이템 선택</FormLabel>
+ <Popover open={commandOpen} onOpenChange={setCommandOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={commandOpen}
+ className="w-full justify-between mt-1"
+ >
+ {selectedItemCode
+ ? `${selectedItemCode} - ${displayItemName}`
+ : "아이템 선택..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="아이템 코드/이름 검색..."
+ onValueChange={setSearchTerm}
+ />
+ <CommandList className="max-h-[200px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm">로딩 중...</div>
+ ) : (
+ <CommandGroup>
+ {filteredItems.map((item) => (
+ <CommandItem
+ key={item.itemCode}
+ value={`${item.itemCode} ${item.itemName}`}
+ onSelect={() => handleSelectItem(item)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedItemCode === item.itemCode
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{item.itemCode}</span>
+ <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 아이템 정보 영역 - 선택된 경우에만 표시 */}
+ {selectedItem && (
+ <div className="rounded-md border p-3 mt-4 overflow-hidden">
+ <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3>
+
+ {/* Item Code - readonly (hidden field) */}
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* Item Name (표시용) */}
+ <div className="mb-2">
+ <p className="text-xs font-medium text-gray-500">Item Name</p>
+ <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p>
+ </div>
+
+ {/* Description (표시용) */}
+ {selectedItem.description && (
+ <div>
+ <p className="text-xs font-medium text-gray-500">Description</p>
+ <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p>
+ </div>
+ )}
+ </div>
+ )}
+
+ </div>
+
+ <DialogFooter className="flex-shrink-0 pt-2">
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || !selectedItemCode}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
)
} \ No newline at end of file