summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/mail/templates/audit-result-notice.hbs164
-rw-r--r--lib/mail/templates/non-inspection-pq.hbs200
-rw-r--r--lib/mail/templates/pq.hbs198
-rw-r--r--lib/mail/templates/site-visit-request.hbs260
-rw-r--r--lib/pq/helper.ts190
-rw-r--r--lib/pq/pq-criteria/add-pq-dialog.tsx344
-rw-r--r--lib/pq/pq-criteria/delete-pqs-dialog.tsx (renamed from lib/pq/table/delete-pqs-dialog.tsx)38
-rw-r--r--lib/pq/pq-criteria/import-pq-button.tsx (renamed from lib/pq/table/import-pq-button.tsx)538
-rw-r--r--lib/pq/pq-criteria/import-pq-handler.tsx (renamed from lib/pq/table/import-pq-handler.tsx)9
-rw-r--r--lib/pq/pq-criteria/pq-excel-template.tsx (renamed from lib/pq/table/pq-excel-template.tsx)0
-rw-r--r--lib/pq/pq-criteria/pq-table-column.tsx (renamed from lib/pq/table/pq-table-column.tsx)93
-rw-r--r--lib/pq/pq-criteria/pq-table-toolbar-actions.tsx (renamed from lib/pq/table/pq-table-toolbar-actions.tsx)57
-rw-r--r--lib/pq/pq-criteria/pq-table.tsx (renamed from lib/pq/table/pq-table.tsx)10
-rw-r--r--lib/pq/pq-criteria/update-pq-sheet.tsx (renamed from lib/pq/table/update-pq-sheet.tsx)592
-rw-r--r--lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx136
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx217
-rw-r--r--lib/pq/pq-review-table-new/feature-flags-provider.tsx216
-rw-r--r--lib/pq/pq-review-table-new/pq-container.tsx300
-rw-r--r--lib/pq/pq-review-table-new/pq-filter-sheet.tsx1300
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx667
-rw-r--r--lib/pq/pq-review-table-new/send-results-dialog.tsx279
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx711
-rw-r--r--lib/pq/pq-review-table-new/user-combobox.tsx242
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx1425
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx756
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx772
-rw-r--r--lib/pq/pq-review-table/vendors-table-columns.tsx4
-rw-r--r--lib/pq/service.ts6559
-rw-r--r--lib/pq/table/add-pq-dialog.tsx454
-rw-r--r--lib/pq/table/add-pq-list-dialog.tsx231
-rw-r--r--lib/pq/table/copy-pq-list-dialog.tsx244
-rw-r--r--lib/pq/table/delete-pq-list-dialog.tsx139
-rw-r--r--lib/pq/table/pq-lists-columns.tsx216
-rw-r--r--lib/pq/table/pq-lists-table.tsx170
-rw-r--r--lib/pq/table/pq-lists-toolbar.tsx61
-rw-r--r--lib/pq/validations.ts199
-rw-r--r--lib/site-visit/client-site-visit-wrapper.tsx474
-rw-r--r--lib/site-visit/service.ts668
-rw-r--r--lib/site-visit/shi-attendees-dialog.tsx152
-rw-r--r--lib/site-visit/site-visit-detail-dialog.tsx266
-rw-r--r--lib/site-visit/vendor-info-sheet.tsx442
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx279
42 files changed, 13278 insertions, 6994 deletions
diff --git a/lib/mail/templates/audit-result-notice.hbs b/lib/mail/templates/audit-result-notice.hbs
new file mode 100644
index 00000000..1e5f7c65
--- /dev/null
+++ b/lib/mail/templates/audit-result-notice.hbs
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{subject}}</title>
+ <style>
+ body {
+ font-family: 'Malgun Gothic', Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ background-color: #f8f9fa;
+ padding: 20px;
+ border-radius: 8px;
+ margin-bottom: 30px;
+ text-align: center;
+ }
+ .content {
+ background-color: #ffffff;
+ padding: 30px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+ .audit-info {
+ background-color: #f8f9fa;
+ padding: 20px;
+ border-radius: 8px;
+ margin-bottom: 30px;
+ }
+ .audit-info table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+ .audit-info td {
+ padding: 8px 12px;
+ border-bottom: 1px solid #e9ecef;
+ }
+ .audit-info td:first-child {
+ font-weight: bold;
+ width: 120px;
+ background-color: #e9ecef;
+ }
+ .result-pass {
+ color: #28a745;
+ font-weight: bold;
+ }
+ .result-fail {
+ color: #dc3545;
+ font-weight: bold;
+ }
+ .footer {
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 1px solid #e9ecef;
+ font-size: 14px;
+ color: #666;
+ }
+ .signature {
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid #e9ecef;
+ }
+ .company-info {
+ margin-top: 10px;
+ font-size: 12px;
+ color: #888;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h2>{{subject}}</h2>
+ </div>
+
+ <div class="content">
+ <div class="audit-info">
+ <table>
+ <tr>
+ <td>PQ No.</td>
+ <td>{{pqNumber}}</td>
+ </tr>
+ <tr>
+ <td>Vendor</td>
+ <td>{{vendorCode}} | {{vendorName}}</td>
+ </tr>
+ <tr>
+ <td>수신자</td>
+ <td>{{recipientName}} / {{recipientEmail}}</td>
+ </tr>
+ <tr>
+ <td>실사품목</td>
+ <td>{{auditItem}}</td>
+ </tr>
+ <tr>
+ <td>실사공장주소</td>
+ <td>{{auditFactoryAddress}}</td>
+ </tr>
+ <tr>
+ <td>실사방법</td>
+ <td>{{auditMethod}}</td>
+ </tr>
+ <tr>
+ <td>실사결과</td>
+ <td class="{{#if (eq auditResult 'Pass(승인)')}}result-pass{{else if (eq auditResult 'Pass(조건부승인)')}}result-pass{{else}}result-fail{{/if}}">
+ {{auditResult}}
+ </td>
+ </tr>
+ {{#if additionalNotes}}
+ <tr>
+ <td>추가 Comment</td>
+ <td>{{additionalNotes}}</td>
+ </tr>
+ {{/if}}
+ </table>
+ </div>
+
+ <div class="email-body">
+ <p>수신 : {{vendorName}} {{recipientName}} 귀하</p>
+ <p>발신 : 삼성중공업 {{senderName}} 프로 ({{senderEmail}})</p>
+
+ <p>귀사 일익 번창하심을 기원합니다.</p>
+
+ <p>당사에선 귀사와의 정기적 거래를 위하여 PQ 검토 및 실사를 진행하였으며,<br>
+ 아래와 같이 최종 실사 결과가 확정되어 공유하여 드립니다.</p>
+
+ <h3>- 아 래 -</h3>
+
+ <ol>
+ <li><strong>실사업체</strong> : {{vendorName}}, {{vendorCode}}</li>
+ <li><strong>실사품목</strong> : {{auditItem}}</li>
+ <li><strong>실사공장주소</strong> : {{auditFactoryAddress}}</li>
+ <li><strong>실사결과</strong> : {{auditResult}}</li>
+ {{#if additionalNotes}}
+ <li><strong>추가 안내사항</strong> : {{additionalNotes}}</li>
+ {{/if}}
+ </ol>
+
+ {{#if (or (eq auditResult 'Pass(승인)') (eq auditResult 'Pass(조건부승인)'))}}
+ <p>이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.</p>
+ {{else}}
+ <p>아쉽게도 이번 실사를 통과하지 못한 점 매우 아쉽게 생각하며<br>
+ 향후에 더 좋은 기회로 귀사와 협업할 수 있기를 기대합니다.</p>
+ {{/if}}
+ </div>
+
+ <div class="signature">
+ <p>{{senderName}} / Procurement Manager / {{senderEmail}}</p>
+ <div class="company-info">
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.<br>
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </div>
+ </div>
+ </div>
+
+ <div class="footer">
+ <p>※ 본 메일은 자동 발송된 메일입니다. 문의사항이 있으시면 발신자에게 연락해 주시기 바랍니다.</p>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/non-inspection-pq.hbs b/lib/mail/templates/non-inspection-pq.hbs
new file mode 100644
index 00000000..add5396b
--- /dev/null
+++ b/lib/mail/templates/non-inspection-pq.hbs
@@ -0,0 +1,200 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>미실사 PQ 요청</title>
+ <style>
+ body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ background-color: #f9f9f9;
+ }
+ .container {
+ background-color: white;
+ padding: 30px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+ .header {
+ text-align: center;
+ border-bottom: 3px solid #2563eb;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+ }
+ .logo {
+ font-size: 24px;
+ font-weight: bold;
+ color: #2563eb;
+ margin-bottom: 10px;
+ }
+ .title {
+ font-size: 20px;
+ font-weight: bold;
+ color: #1f2937;
+ margin-bottom: 5px;
+ }
+ .subtitle {
+ color: #6b7280;
+ font-size: 14px;
+ }
+ .content {
+ margin-bottom: 30px;
+ }
+ .section {
+ margin-bottom: 25px;
+ }
+ .section-title {
+ font-weight: bold;
+ color: #1f2937;
+ margin-bottom: 10px;
+ font-size: 16px;
+ }
+ .info-box {
+ background-color: #f3f4f6;
+ padding: 15px;
+ border-radius: 6px;
+ margin-bottom: 20px;
+ }
+ .info-item {
+ margin-bottom: 8px;
+ }
+ .info-label {
+ font-weight: bold;
+ color: #374151;
+ }
+ .info-value {
+ color: #1f2937;
+ }
+ .highlight {
+ background-color: #fef3c7;
+ padding: 15px;
+ border-radius: 6px;
+ border-left: 4px solid #f59e0b;
+ margin: 20px 0;
+ }
+ .button {
+ display: inline-block;
+ background-color: #2563eb;
+ color: white;
+ padding: 12px 24px;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: bold;
+ margin: 20px 0;
+ }
+ .button:hover {
+ background-color: #1d4ed8;
+ }
+ .footer {
+ text-align: center;
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 1px solid #e5e7eb;
+ color: #6b7280;
+ font-size: 12px;
+ }
+ .contracts {
+ background-color: #f0f9ff;
+ padding: 15px;
+ border-radius: 6px;
+ margin: 15px 0;
+ }
+ .contract-item {
+ margin-bottom: 5px;
+ color: #1e40af;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <div class="logo">eVCP</div>
+ <div class="title">미실사 PQ 요청</div>
+ <div class="subtitle">Non-Inspection Pre-Qualification Request</div>
+ </div>
+
+ <div class="content">
+ <p>안녕하세요, <strong>{{vendorName}}</strong>님</p>
+
+ <p>SHI에서 미실사 PQ(Pre-Qualification) 요청을 보냅니다.</p>
+
+ <div class="info-box">
+ <div class="info-item">
+ <span class="info-label">PQ 번호:</span>
+ <span class="info-value">{{pqNumber}}</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">요청자:</span>
+ <span class="info-value">{{senderName}}</span>
+ </div>
+ {{#if dueDate}}
+ <div class="info-item">
+ <span class="info-label">제출 마감일:</span>
+ <span class="info-value">{{dueDate}}</span>
+ </div>
+ {{/if}}
+ </div>
+
+ <div class="section">
+ <div class="section-title">📋 미실사 PQ란?</div>
+ <p>미실사 PQ는 현장 방문 없이 서류 검토만으로 진행되는 사전 자격 검증입니다.
+ 일반 PQ와 동일한 기준으로 평가되지만, 현장 실사 과정이 생략됩니다.</p>
+ </div>
+
+ {{#if pqItems}}
+ <div class="section">
+ <div class="section-title">🎯 PQ 대상 품목</div>
+ <div class="highlight">
+ {{pqItems}}
+ </div>
+ </div>
+ {{/if}}
+
+ {{#if contracts.length}}
+ <div class="section">
+ <div class="section-title">📄 포함된 계약 항목</div>
+ <div class="contracts">
+ {{#each contracts}}
+ <div class="contract-item">• {{this}}</div>
+ {{/each}}
+ </div>
+ </div>
+ {{/if}}
+
+ {{#if extraNote}}
+ <div class="section">
+ <div class="section-title">📝 추가 안내사항</div>
+ <div class="highlight">
+ {{extraNote}}
+ </div>
+ </div>
+ {{/if}}
+
+ <div class="section">
+ <div class="section-title">🚀 PQ 제출하기</div>
+ <p>아래 버튼을 클릭하여 미실사 PQ를 제출하세요:</p>
+ <a href="{{loginUrl}}" class="button">PQ 제출하기</a>
+ </div>
+
+ <div class="section">
+ <div class="section-title">⚠️ 중요 안내</div>
+ <ul>
+ <li>미실사 PQ는 서류 검토만으로 진행되므로, 모든 서류를 정확히 작성해주세요.</li>
+ <li>제출 후에는 수정이 제한될 수 있으니 신중하게 작성해주세요.</li>
+ <li>문의사항이 있으시면 언제든 연락주세요.</li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="footer">
+ <p>© {{currentYear}} eVCP. All rights reserved.</p>
+ <p>본 메일은 자동으로 발송되었습니다. 문의사항은 {{senderEmail}}로 연락주세요.</p>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/pq.hbs b/lib/mail/templates/pq.hbs
index a8876eeb..0f54adb1 100644
--- a/lib/mail/templates/pq.hbs
+++ b/lib/mail/templates/pq.hbs
@@ -1,90 +1,120 @@
<!DOCTYPE html>
-<html>
+<html lang="ko">
<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>eVCP 메일</title>
- <style>
- body {
- margin: 0 !important;
- padding: 20px !important;
- background-color: #f4f4f4;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
- }
- .email-container {
- max-width: 600px;
- margin: 0 auto;
- background-color: #ffffff;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
- }
- </style>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>eVCP PQ 초대</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ }
+ .email-container {
+ max-width: 700px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 24px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ color: #111827;
+ }
+ .section-title {
+ font-weight: bold;
+ margin-top: 24px;
+ }
+ .contract-list {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding-left: 1em;
+ }
+ </style>
</head>
<body>
- <div class="email-container">
-<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
- <tr>
- <td align="center">
- <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
- </td>
- </tr>
-</table>
-
-<h1 style="font-size:28px; margin-bottom:16px;">
- eVCP PQ 초대
-</h1>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- {{vendorName}} 귀하,
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- 귀사를 저희 업체 데이터베이스에 사전적격심사(PQ) 정보를 제출하도록 초대합니다. 이 과정을 완료하면 향후 프로젝트 및 조달 기회에 귀사가 고려될 수 있습니다.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- PQ 정보 제출 방법:
-</p>
-
-<ol style="font-size:16px; line-height:32px; margin-bottom:16px;">
- <li>아래 버튼을 클릭하여 저희 업체 포털에 접속하세요</li>
- <li>계정에 로그인하세요 (아직 계정이 없으면 등록하세요)</li>
- <li>대시보드에서 PQ 섹션으로 이동하세요</li>
- <li>귀사, 역량 및 경험에 관한 모든 필수 정보를 작성하세요</li>
-</ol>
-
-<p style="text-align: center;">
- <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;">
- 업체 포털 접속
- </a>
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 시스템에 최신 PQ 정보를 유지하는 것은 향후 기회에 귀사가 고려되기 위해 필수적입니다.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 문의사항이 있거나 도움이 필요하시면 저희 업체 관리팀에 문의해 주세요.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 귀사에 대해 더 알아보고 향후 프로젝트에서 함께 일할 수 있기를 기대합니다.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 감사합니다,<br>
- eVCP 팀
-</p>
-
-<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
- <tr>
- <td align="center">
- <p style="font-size:16px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p>
- <p style="font-size:16px; color:#6b7280; margin:4px 0;">{{t "email.vendor.invitation.no_reply"}}</p>
- </td>
- </tr>
-</table>
+ <div class="email-container">
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+
+ <h1 style="font-size:28px; margin-bottom:16px;">
+ [SHI PQ] Pre-Qualification Invitation _ {{vendorName}} _ PQ No. {{pqNumber}}
+ </h1>
+
+ <p style="font-size:16px; line-height:32px;">SHI PQ No. : {{pqNumber}}</p>
+ <p style="font-size:16px; line-height:32px;">수신 : {{vendorName}} {{vendorContact}} 귀하</p>
+ <p style="font-size:16px; line-height:32px;">발신 : {{senderName}} 프로 ({{senderEmail}})</p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:16px;">
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 당사에선 귀사와의 정기적 거래를 위하여 PQ(Pre-Qualification)을 진행하고자 합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 요청드린 PQ 항목 및 자료에 대한 제출 요청드립니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 별도의 견적을 제출하시어 당사에서 적극 검토할 수 있도록 협조 바랍니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 귀사의 제출 자료 및 정보는 아래의 제출 마감일 이전에 당사로 제출 되어야 하며,
+ 마감일 전 별도의 지연 통보 없이 미 제출될 경우에는 추후 계약대상자 등재에 어려움이 있을 수 있습니다.
+ </p>
+
+ <p class="section-title">- 아 래 -</p>
+
+ <p style="font-size:16px; line-height:32px;">1) PQ 제출 마감일 : {{dueDate}}</p>
+
+ <p style="font-size:16px; line-height:32px;">2) PQ 제출 방법</p>
+ <ul style="font-size:16px; line-height:32px; padding-left:1.2em; margin-top:4px;">
+ <li>아래 eVCP 접속 링크 클릭</li>
+ <li>eVCP 로그인 (계정이 없을 경우 계정 생성 필요)</li>
+ <li>PQ 필수 입력사항 및 제출자료 입력 후 제출 버튼 클릭</li>
+ </ul>
+
+ <p style="font-size:16px; line-height:32px;">3) PQ 대상품목 : {{pqItems}}</p>
+
+ <p style="font-size:16px; line-height:32px;">4) 기본계약서 승인(서명) 및 자료 제출 요청</p>
+ <div class="contract-list">
+ {{#each contracts}}
+ <div>■ {{this}}</div>
+ {{/each}}
</div>
+
+ {{#if extraNote}}
+ <p style="font-size:16px; line-height:32px;">5) 추가 안내사항</p>
+ <div style="white-space: pre-line; font-size:16px; line-height:32px;">
+ {{extraNote}}
+ </div>
+ {{/if}}
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ {{senderName}} / Procurement Manager / {{senderEmail}}<br>
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.<br>
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">본 메일은 발신전용입니다. 회신하지 마십시오.</p>
+ </td>
+ </tr>
+ </table>
+ </div>
</body>
-</html> \ No newline at end of file
+</html>
diff --git a/lib/mail/templates/site-visit-request.hbs b/lib/mail/templates/site-visit-request.hbs
new file mode 100644
index 00000000..6b2c3a2a
--- /dev/null
+++ b/lib/mail/templates/site-visit-request.hbs
@@ -0,0 +1,260 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>방문실사 요청</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ line-height: 1.6;
+ }
+ .email-container {
+ max-width: 600px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 30px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+ .header {
+ border-bottom: 2px solid #163CC4;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+ }
+ .company-info {
+ background-color: #f8f9fa;
+ padding: 20px;
+ border-radius: 6px;
+ margin: 20px 0;
+ border-left: 4px solid #163CC4;
+ }
+ .section {
+ margin: 20px 0;
+ }
+ .section-title {
+ font-weight: bold;
+ color: #163CC4;
+ margin-bottom: 10px;
+ font-size: 16px;
+ }
+ .info-item {
+ margin: 8px 0;
+ padding-left: 20px;
+ }
+ .info-label {
+ font-weight: bold;
+ color: #374151;
+ }
+ .info-value {
+ color: #1f2937;
+ }
+ .attendees-list {
+ list-style: none;
+ padding-left: 20px;
+ }
+ .attendees-list li {
+ margin: 5px 0;
+ padding-left: 15px;
+ position: relative;
+ }
+ .attendees-list li:before {
+ content: "•";
+ color: #163CC4;
+ font-weight: bold;
+ position: absolute;
+ left: 0;
+ }
+ .request-items {
+ list-style: none;
+ padding-left: 20px;
+ }
+ .request-items li {
+ margin: 8px 0;
+ padding-left: 15px;
+ position: relative;
+ }
+ .request-items li:before {
+ content: "○";
+ color: #163CC4;
+ font-weight: bold;
+ position: absolute;
+ left: 0;
+ }
+ .footer {
+ margin-top: 40px;
+ padding-top: 20px;
+ border-top: 1px solid #e5e7eb;
+ text-align: center;
+ color: #6b7280;
+ font-size: 14px;
+ }
+ .deadline {
+ background-color: #fef3c7;
+ border: 1px solid #f59e0b;
+ padding: 15px;
+ border-radius: 6px;
+ margin: 20px 0;
+ }
+ .deadline strong {
+ color: #d97706;
+ }
+ </style>
+</head>
+<body>
+ <div class="email-container">
+ <!-- 헤더 -->
+ <div class="header">
+ <table width="100%" cellpadding="0" cellspacing="0">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ <!-- 수신/발신 정보 -->
+ <div class="company-info">
+ <div style="margin-bottom: 15px;">
+ <span class="info-label">수신:</span>
+ <span class="info-value">{{vendorName}} {{vendorContactName}} 귀하</span>
+ </div>
+ <div>
+ <span class="info-label">발신:</span>
+ <span class="info-value">{{requesterName}} {{requesterTitle}} ({{requesterEmail}})</span>
+ </div>
+ </div>
+
+ <!-- 인사말 -->
+ <p style="font-size: 16px; margin-bottom: 20px;">
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <!-- 본문 -->
+ <p style="font-size: 16px; margin-bottom: 20px;">
+ 당사에선 귀사와의 정기적 거래를 위하여 귀사가 당사의 기준에 적합한 협력업체인지를 검토하기 위하여<br>
+ 귀사의 실 제작 공장을 직접 방문하여 점검하는 방문실사를 진행하고자 합니다.
+ </p>
+
+ <p style="font-size: 16px; margin-bottom: 20px;">
+ 방문실사를 위하여 다음과 같이 관련정보 및 요청정보/자료를 전달드리오니<br>
+ 메일 발신일 기준 C/D +7일 이내에 정보 입력 및 자료를 제출하시어<br>
+ 당사에서 귀사의 실 제작 공장 방문을 미리 준비할 수 있도록 적극적인 협조 부탁드립니다.
+ </p>
+
+ <!-- 마감일 안내 -->
+ <div class="deadline">
+ <strong>📅 제출 마감일: {{deadlineDate}}</strong>
+ </div>
+
+ <!-- 구분선 -->
+ <div style="text-align: center; margin: 30px 0;">
+ <span style="font-weight: bold; font-size: 18px; color: #163CC4;">- 다 음 -</span>
+ </div>
+
+ <!-- 실사 정보 -->
+ <div class="section">
+ <div class="section-title">1. 실사방법</div>
+ <div class="info-item">
+ <span class="info-value">{{evaluationType}}</span>
+ {{#if evaluationTypeDescription}}
+ <div style="font-size: 14px; color: #6b7280; margin-top: 5px;">
+ (안내: {{evaluationTypeDescription}})
+ </div>
+ {{/if}}
+ </div>
+ </div>
+
+ <div class="section">
+ <div class="section-title">2. 실사요청일 및 기간</div>
+ <div class="info-item">
+ <span class="info-value">{{requestedStartDate}} ~ {{requestedEndDate}} (W/D 기준 {{inspectionDuration}}일)</span>
+ </div>
+ </div>
+
+ <div class="section">
+ <div class="section-title">3. 삼성중공업 실사 참석 예정 부문</div>
+ {{#if shiAttendees}}
+ <ul class="attendees-list">
+ {{#each shiAttendees}}
+ <li>{{this}}</li>
+ {{/each}}
+ </ul>
+ {{else}}
+ <div class="info-item">
+ <span class="info-value">참석 예정 부문이 없습니다.</span>
+ </div>
+ {{/if}}
+ {{#if shiAttendeeDetails}}
+ <div style="margin-top: 10px; padding-left: 20px; font-size: 14px; color: #6b7280;">
+ <strong>참석자 상세정보:</strong><br>
+ {{shiAttendeeDetails}}
+ </div>
+ {{/if}}
+ </div>
+
+ <div class="section">
+ <div class="section-title">4. 협력업체 요청정보 및 자료</div>
+ <ul class="request-items">
+ {{#each vendorRequests}}
+ <li>{{this}}</li>
+ {{/each}}
+ </ul>
+ {{#if otherVendorRequests}}
+ <div style="margin-top: 10px; padding-left: 20px; font-size: 14px; color: #6b7280;">
+ {{otherVendorRequests}}
+ </div>
+ {{/if}}
+ </div>
+
+ {{#if additionalRequests}}
+ <div class="section">
+ <div class="section-title">5. 추가 요청사항</div>
+ <div class="info-item">
+ <span class="info-value">{{additionalRequests}}</span>
+ </div>
+ </div>
+ {{/if}}
+
+ <!-- 문의사항 -->
+ <div style="margin: 30px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
+ <p style="font-size: 16px; margin: 0;">
+ 상기 내역에 대해 문의사항이 있을 경우 구매 담당자에게 연락 바랍니다.
+ </p>
+ </div>
+
+ <!-- 마무리 -->
+ <p style="font-size: 16px; margin-bottom: 20px;">감사합니다.</p>
+
+ <!-- 발신자 정보 -->
+ <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
+ <p style="font-size: 14px; margin: 5px 0; color: #374151;">
+ {{requesterName}} / {{requesterTitle}} / {{requesterEmail}}
+ </p>
+ <p style="font-size: 14px; margin: 5px 0; color: #374151;">
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.
+ </p>
+ <p style="font-size: 14px; margin: 5px 0; color: #374151;">
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+ </div>
+
+ <!-- 포털 링크 -->
+ <div style="text-align: center; margin: 30px 0;">
+ <a href="{{portalUrl}}" target="_blank" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:12px 24px; text-decoration:none; border-radius:6px; font-weight:bold;">
+ 협력업체 정보 입력하기
+ </a>
+ </div>
+
+ <!-- 푸터 -->
+ <div class="footer">
+ <p style="margin: 4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="margin: 4px 0;">이 메일은 자동으로 발송되었습니다. 회신하지 마세요.</p>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/pq/helper.ts b/lib/pq/helper.ts
index 16aed0e4..efd50714 100644
--- a/lib/pq/helper.ts
+++ b/lib/pq/helper.ts
@@ -1,96 +1,96 @@
-import {
- vendorPQSubmissions,
- vendors,
- projects,
- users,
- vendorInvestigations
-} from "@/db/schema"
-import { CustomColumnMapping } from "../filter-columns"
-
-/**
- * Helper function to create custom column mapping for PQ submissions
- */
-export function createPQFilterMapping(): CustomColumnMapping {
- return {
- // PQ 제출 관련
- pqNumber: { table: vendorPQSubmissions, column: "pqNumber" },
- status: { table: vendorPQSubmissions, column: "status" },
- type: { table: vendorPQSubmissions, column: "type" },
- createdAt: { table: vendorPQSubmissions, column: "createdAt" },
- updatedAt: { table: vendorPQSubmissions, column: "updatedAt" },
- submittedAt: { table: vendorPQSubmissions, column: "submittedAt" },
- approvedAt: { table: vendorPQSubmissions, column: "approvedAt" },
- rejectedAt: { table: vendorPQSubmissions, column: "rejectedAt" },
-
- // 협력업체 관련
- vendorName: { table: vendors, column: "vendorName" },
- vendorCode: { table: vendors, column: "vendorCode" },
- taxId: { table: vendors, column: "taxId" },
- vendorStatus: { table: vendors, column: "status" },
-
- // 프로젝트 관련
- projectName: { table: projects, column: "name" },
- projectCode: { table: projects, column: "code" },
-
- // 요청자 관련
- requesterName: { table: users, column: "name" },
- requesterEmail: { table: users, column: "email" },
-
- // 실사 관련
- evaluationResult: { table: vendorInvestigations, column: "evaluationResult" },
- evaluationType: { table: vendorInvestigations, column: "evaluationType" },
- investigationStatus: { table: vendorInvestigations, column: "investigationStatus" },
- investigationAddress: { table: vendorInvestigations, column: "investigationAddress" },
- qmManagerId: { table: vendorInvestigations, column: "qmManagerId" },
- }
-}
-
-/**
- * PQ 관련 조인 테이블들
- */
-export function getPQJoinedTables() {
- return {
- vendors,
- projects,
- users,
- vendorInvestigations,
- }
-}
-
-/**
- * 직접 컬럼 참조 방식의 매핑 (더 타입 안전)
- */
-export function createPQDirectColumnMapping(): CustomColumnMapping {
- return {
- // PQ 제출 관련 - 직접 컬럼 참조
- pqNumber: vendorPQSubmissions.pqNumber,
- status: vendorPQSubmissions.status,
- type: vendorPQSubmissions.type,
- createdAt: vendorPQSubmissions.createdAt,
- updatedAt: vendorPQSubmissions.updatedAt,
- submittedAt: vendorPQSubmissions.submittedAt,
- approvedAt: vendorPQSubmissions.approvedAt,
- rejectedAt: vendorPQSubmissions.rejectedAt,
-
- // 협력업체 관련
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- taxId: vendors.taxId,
- vendorStatus: vendors.status,
-
- // 프로젝트 관련
- projectName: projects.name,
- projectCode: projects.code,
-
- // 요청자 관련
- requesterName: users.name,
- requesterEmail: users.email,
-
- // 실사 관련
- evaluationResult: vendorInvestigations.evaluationResult,
- evaluationType: vendorInvestigations.evaluationType,
- investigationStatus: vendorInvestigations.investigationStatus,
- investigationAddress: vendorInvestigations.investigationAddress,
- qmManagerId: vendorInvestigations.qmManagerId,
- }
+import {
+ vendorPQSubmissions,
+ vendors,
+ projects,
+ users,
+ vendorInvestigations
+} from "@/db/schema"
+import { CustomColumnMapping } from "../filter-columns"
+
+/**
+ * Helper function to create custom column mapping for PQ submissions
+ */
+export function createPQFilterMapping(): CustomColumnMapping {
+ return {
+ // PQ 제출 관련
+ pqNumber: { table: vendorPQSubmissions, column: "pqNumber" },
+ status: { table: vendorPQSubmissions, column: "status" },
+ type: { table: vendorPQSubmissions, column: "type" },
+ createdAt: { table: vendorPQSubmissions, column: "createdAt" },
+ updatedAt: { table: vendorPQSubmissions, column: "updatedAt" },
+ submittedAt: { table: vendorPQSubmissions, column: "submittedAt" },
+ approvedAt: { table: vendorPQSubmissions, column: "approvedAt" },
+ rejectedAt: { table: vendorPQSubmissions, column: "rejectedAt" },
+
+ // 협력업체 관련
+ vendorName: { table: vendors, column: "vendorName" },
+ vendorCode: { table: vendors, column: "vendorCode" },
+ taxId: { table: vendors, column: "taxId" },
+ vendorStatus: { table: vendors, column: "status" },
+
+ // 프로젝트 관련
+ projectName: { table: projects, column: "name" },
+ projectCode: { table: projects, column: "code" },
+
+ // 요청자 관련
+ requesterName: { table: users, column: "name" },
+ requesterEmail: { table: users, column: "email" },
+
+ // 실사 관련
+ evaluationResult: { table: vendorInvestigations, column: "evaluationResult" },
+ evaluationType: { table: vendorInvestigations, column: "evaluationType" },
+ investigationStatus: { table: vendorInvestigations, column: "investigationStatus" },
+ investigationAddress: { table: vendorInvestigations, column: "investigationAddress" },
+ qmManagerId: { table: vendorInvestigations, column: "qmManagerId" },
+ }
+}
+
+/**
+ * PQ 관련 조인 테이블들
+ */
+export function getPQJoinedTables() {
+ return {
+ vendors,
+ projects,
+ users,
+ vendorInvestigations,
+ }
+}
+
+/**
+ * 직접 컬럼 참조 방식의 매핑 (더 타입 안전)
+ */
+export function createPQDirectColumnMapping(): CustomColumnMapping {
+ return {
+ // PQ 제출 관련 - 직접 컬럼 참조
+ pqNumber: vendorPQSubmissions.pqNumber,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+
+ // 협력업체 관련
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
+ vendorStatus: vendors.status,
+
+ // 프로젝트 관련
+ projectName: projects.name,
+ projectCode: projects.code,
+
+ // 요청자 관련
+ requesterName: users.name,
+ requesterEmail: users.email,
+
+ // 실사 관련
+ evaluationResult: vendorInvestigations.evaluationResult,
+ evaluationType: vendorInvestigations.evaluationType,
+ investigationStatus: vendorInvestigations.investigationStatus,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ qmManagerId: vendorInvestigations.qmManagerId,
+ }
} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/add-pq-dialog.tsx b/lib/pq/pq-criteria/add-pq-dialog.tsx
new file mode 100644
index 00000000..53fe28f1
--- /dev/null
+++ b/lib/pq/pq-criteria/add-pq-dialog.tsx
@@ -0,0 +1,344 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Plus } from "lucide-react"
+import { useRouter } from "next/navigation"
+
+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 { Textarea } from "@/components/ui/textarea"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { useToast } from "@/hooks/use-toast"
+import { createPqCriteria } from "../service"
+
+// PQ 생성을 위한 Zod 스키마 정의
+const createPqSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ subGroupName: z.string().min(1, "Sub group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional(),
+ inputFormat: z.string().default("TEXT"),
+
+});
+
+type CreatePqFormType = z.infer<typeof createPqSchema>;
+
+// 그룹 이름 옵션
+export const groupOptions = [
+ "GENERAL",
+ "QMS",
+ "Warranty",
+ "HSE+",
+ "기타",
+];
+
+// 입력 형식 옵션
+const inputFormatOptions = [
+ { value: "TEXT", label: "텍스트" },
+ { value: "FILE", label: "파일" },
+ { value: "EMAIL", label: "이메일" },
+ { value: "PHONE", label: "전화번호" },
+ { value: "NUMBER", label: "숫자" },
+ { value: "TEXT_FILE", label: "텍스트 + 파일" },
+];
+
+interface AddPqDialogProps {
+ pqListId: number;
+}
+
+export function AddPqDialog({ pqListId }: AddPqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const router = useRouter()
+ const { toast } = useToast()
+
+ // react-hook-form 설정
+ const form = useForm<CreatePqFormType>({
+ resolver: zodResolver(createPqSchema),
+ defaultValues: {
+ code: "",
+ checkPoint: "",
+ groupName: groupOptions[0],
+ subGroupName: "",
+ description: "",
+ remarks: "",
+ inputFormat: "TEXT",
+
+ },
+ })
+ const formState = form.formState
+
+ async function onSubmit(data: CreatePqFormType) {
+ try {
+ setIsSubmitting(true)
+
+ // 서버 액션 호출
+ const result = await createPqCriteria(pqListId, data)
+
+ if (!result.success) {
+ toast({
+ title: "오류",
+ description: result.message || "PQ 항목 생성에 실패했습니다",
+ variant: "destructive",
+ })
+ return
+ }
+
+ // 성공 시 처리
+ toast({
+ title: "성공",
+ description: result.message || "PQ 항목이 성공적으로 생성되었습니다",
+ })
+
+ // 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(false)
+
+ // 페이지 새로고침
+ router.refresh()
+
+ } catch (error) {
+ console.error('Error creating PQ criteria:', error)
+ toast({
+ title: "오류",
+ description: "예상치 못한 오류가 발생했습니다",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="size-4" />
+ Add PQ
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>PQ 항목 생성</DialogTitle>
+ <DialogDescription>
+ 새 PQ 항목을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-auto space-y-4">
+ <div className="space-y-4 px-1">
+ {/* Group Name 필드 */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대분류 <span className="text-destructive">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Sub Group Name 필드 */}
+ <FormField
+ control={form.control}
+ name="subGroupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소분류 <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="서브 그룹명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 세부 분류를 위한 서브 그룹명을 입력하세요 (선택사항)
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>일련번호 <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ PQ 항목의 고유 코드를 입력하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 항목 <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="PQ 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Input Format 필드 */}
+ <FormField
+ control={form.control}
+ name="inputFormat"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>협력업체 입력사항 <span className="text-destructive">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입력 형식을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {inputFormatOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Description 필드 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 설명을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset();
+ setOpen(false);
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !formState.isValid}
+ >
+ {isSubmitting ? "생성 중..." : "생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/table/delete-pqs-dialog.tsx b/lib/pq/pq-criteria/delete-pqs-dialog.tsx
index c6a2ce82..45fa065f 100644
--- a/lib/pq/table/delete-pqs-dialog.tsx
+++ b/lib/pq/pq-criteria/delete-pqs-dialog.tsx
@@ -28,7 +28,7 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer"
import { PqCriterias } from "@/db/schema/pq"
-import { removePqs } from "../service"
+import { deletePqCriterias } from "../service"
interface DeleteTasksDialogProps
@@ -49,17 +49,15 @@ export function DeletePqsDialog({
function onDelete() {
startDeleteTransition(async () => {
- const { error } = await removePqs({
- ids: pqs.map((pq) => pq.id),
- })
+ const result = await deletePqCriterias(pqs.map((pq) => pq.id))
- if (error) {
- toast.error(error)
+ if (!result.success) {
+ toast.error(result.message || "PQ 항목 삭제에 실패했습니다")
return
}
props.onOpenChange?.(false)
- toast.success("Tasks deleted")
+ toast.success(result.message || "PQ 항목이 삭제되었습니다")
onSuccess?.()
})
}
@@ -77,16 +75,17 @@ export function DeletePqsDialog({
) : null}
<DialogContent>
<DialogHeader>
- <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
<DialogDescription>
- This action cannot be undone. This will permanently delete your{" "}
- <span className="font-medium">{pqs.length}</span>
- {pqs.length === 1 ? " PQ" : " PQs"} from our servers.
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{pqs.length}개</span>의 PQ 항목이 영구적으로 삭제됩니다.
+ <br />
+ <span className="text-red-600 font-medium">삭제 시 하위 PQ항목들 전부 삭제됩니다.</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:space-x-0">
<DialogClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DialogClose>
<Button
aria-label="Delete selected rows"
@@ -100,7 +99,7 @@ export function DeletePqsDialog({
aria-hidden="true"
/>
)}
- Delete
+ 삭제
</Button>
</DialogFooter>
</DialogContent>
@@ -120,16 +119,17 @@ export function DeletePqsDialog({
) : null}
<DrawerContent>
<DrawerHeader>
- <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
<DrawerDescription>
- This action cannot be undone. This will permanently delete your{" "}
- <span className="font-medium">{pqs.length}</span>
- {pqs.length === 1 ? " task" : " pqs"} from our servers.
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{pqs.length}개</span>의 PQ 항목이 영구적으로 삭제됩니다.
+ <br />
+ <span className="text-red-600 font-medium">삭제 시 하위 PQ항목들 전부 삭제됩니다.</span>
</DrawerDescription>
</DrawerHeader>
<DrawerFooter className="gap-2 sm:space-x-0">
<DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DrawerClose>
<Button
aria-label="Delete selected rows"
@@ -140,7 +140,7 @@ export function DeletePqsDialog({
{isDeletePending && (
<Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
)}
- Delete
+ 삭제
</Button>
</DrawerFooter>
</DrawerContent>
diff --git a/lib/pq/table/import-pq-button.tsx b/lib/pq/pq-criteria/import-pq-button.tsx
index 2fbf66d9..d338abd4 100644
--- a/lib/pq/table/import-pq-button.tsx
+++ b/lib/pq/pq-criteria/import-pq-button.tsx
@@ -1,270 +1,270 @@
-"use client"
-
-import * as React from "react"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-import * as ExcelJS from 'exceljs'
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Progress } from "@/components/ui/progress"
-import { processFileImport } from "./import-pq-handler" // 별도 파일로 분리
-import { decryptWithServerAction } from "@/components/drm/drmUtils"
-
-interface ImportPqButtonProps {
- projectId?: number | null
- onSuccess?: () => void
-}
-
-export function ImportPqButton({ projectId, onSuccess }: ImportPqButtonProps) {
- const [open, setOpen] = React.useState(false)
- const [file, setFile] = React.useState<File | null>(null)
- const [isUploading, setIsUploading] = React.useState(false)
- const [progress, setProgress] = React.useState(0)
- const [error, setError] = React.useState<string | null>(null)
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 파일 선택 처리
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0]
- if (!selectedFile) return
-
- if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
- setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
- return
- }
-
- setFile(selectedFile)
- setError(null)
- }
-
- // 데이터 가져오기 처리
- const handleImport = async () => {
- if (!file) {
- setError("가져올 파일을 선택해주세요.")
- return
- }
-
- try {
- setIsUploading(true)
- setProgress(0)
- setError(null)
-
- // DRM 복호화 처리 - 서버 액션 직접 호출
- let arrayBuffer: ArrayBuffer;
- try {
- setProgress(10);
- toast.info("파일 복호화 중...");
- arrayBuffer = await decryptWithServerAction(file);
- setProgress(30);
- } catch (decryptError) {
- console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
- toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
- // 복호화 실패 시 원본 파일 사용
- arrayBuffer = await file.arrayBuffer();
- }
-
- // ExcelJS 워크북 로드
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.load(arrayBuffer);
-
- // 첫 번째 워크시트 가져오기
- const worksheet = workbook.worksheets[0];
- if (!worksheet) {
- throw new Error("Excel 파일에 워크시트가 없습니다.");
- }
-
- // 헤더 행 번호 찾기 (보통 지침 행이 있으므로 헤더는 뒤에 위치)
- let headerRowIndex = 1;
- let headerRow: ExcelJS.Row | undefined;
- let headerValues: (string | null)[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- const values = row.values as (string | null)[];
- if (!headerRow && values.some(v => v === "Code" || v === "Check Point") && rowNumber > 1) {
- headerRowIndex = rowNumber;
- headerRow = row;
- headerValues = [...values];
- }
- });
-
- if (!headerRow) {
- throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
- }
-
- // 헤더를 기반으로 인덱스 매핑 생성
- const headerMapping: Record<string, number> = {};
- headerValues.forEach((value, index) => {
- if (typeof value === 'string') {
- headerMapping[value] = index;
- }
- });
-
- // 필수 헤더 확인
- const requiredHeaders = ["Code", "Check Point", "Group Name"];
- const missingHeaders = requiredHeaders.filter(header => !(header in headerMapping));
-
- if (missingHeaders.length > 0) {
- throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
- }
-
- // 데이터 행 추출 (헤더 이후 행부터)
- const dataRows: Record<string, any>[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > headerRowIndex) {
- const rowData: Record<string, any> = {};
- const values = row.values as (string | null | undefined)[];
-
- Object.entries(headerMapping).forEach(([header, index]) => {
- rowData[header] = values[index] || "";
- });
-
- // 빈 행이 아닌 경우만 추가
- if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
- dataRows.push(rowData);
- }
- }
- });
-
- if (dataRows.length === 0) {
- throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
- }
-
- // 진행 상황 업데이트를 위한 콜백
- const updateProgress = (current: number, total: number) => {
- const percentage = Math.round((current / total) * 100);
- setProgress(percentage);
- };
-
- // 실제 데이터 처리는 별도 함수에서 수행
- const result = await processFileImport(
- dataRows,
- projectId,
- updateProgress
- );
-
- // 처리 완료
- toast.success(`${result.successCount}개의 PQ 항목이 성공적으로 가져와졌습니다.`);
-
- if (result.errorCount > 0) {
- toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`);
- }
-
- // 상태 초기화 및 다이얼로그 닫기
- setFile(null);
- setOpen(false);
-
- // 성공 콜백 호출
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("Excel 파일 처리 중 오류 발생:", error);
- setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
- } finally {
- setIsUploading(false);
- }
- };
-
- // 다이얼로그 열기/닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen) {
- // 닫을 때 상태 초기화
- setFile(null)
- setError(null)
- setProgress(0)
- if (fileInputRef.current) {
- fileInputRef.current.value = ""
- }
- }
- setOpen(newOpen)
- }
-
- return (
- <>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => setOpen(true)}
- disabled={isUploading}
- >
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Import</span>
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>PQ 항목 가져오기</DialogTitle>
- <DialogDescription>
- {projectId
- ? "프로젝트별 PQ 항목을 Excel 파일에서 가져옵니다."
- : "일반 PQ 항목을 Excel 파일에서 가져옵니다."}
- <br />
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4 py-4">
- <div className="flex items-center gap-4">
- <input
- type="file"
- ref={fileInputRef}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isUploading}
- />
- </div>
-
- {file && (
- <div className="text-sm text-muted-foreground">
- 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
- </div>
- )}
-
- {isUploading && (
- <div className="space-y-2">
- <Progress value={progress} />
- <p className="text-sm text-muted-foreground text-center">
- {progress}% 완료
- </p>
- </div>
- )}
-
- {error && (
- <div className="text-sm font-medium text-destructive">
- {error}
- </div>
- )}
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button
- onClick={handleImport}
- disabled={!file || isUploading}
- >
- {isUploading ? "처리 중..." : "가져오기"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- )
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { processFileImport } from "./import-pq-handler" // 별도 파일로 분리
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+interface ImportPqButtonProps {
+ projectId?: number | null
+ onSuccess?: () => void
+}
+
+export function ImportPqButton({ projectId, onSuccess }: ImportPqButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // DRM 복호화 처리 - 서버 액션 직접 호출
+ let arrayBuffer: ArrayBuffer;
+ try {
+ setProgress(10);
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ setProgress(30);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ // 복호화 실패 시 원본 파일 사용
+ arrayBuffer = await file.arrayBuffer();
+ }
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 번호 찾기 (보통 지침 행이 있으므로 헤더는 뒤에 위치)
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "Code" || v === "Check Point") && rowNumber > 1) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["Code", "Check Point", "Group Name"];
+ const missingHeaders = requiredHeaders.filter(header => !(header in headerMapping));
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출 (헤더 이후 행부터)
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 실제 데이터 처리는 별도 함수에서 수행
+ const result = await processFileImport(
+ dataRows,
+ projectId,
+ updateProgress
+ );
+
+ // 처리 완료
+ toast.success(`${result.successCount}개의 PQ 항목이 성공적으로 가져와졌습니다.`);
+
+ if (result.errorCount > 0) {
+ toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`);
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null)
+ setError(null)
+ setProgress(0)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ setOpen(newOpen)
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>PQ 항목 가져오기</DialogTitle>
+ <DialogDescription>
+ {projectId
+ ? "프로젝트별 PQ 항목을 Excel 파일에서 가져옵니다."
+ : "일반 PQ 항목을 Excel 파일에서 가져옵니다."}
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/pq/table/import-pq-handler.tsx b/lib/pq/pq-criteria/import-pq-handler.tsx
index 13431ba7..3ee30c88 100644
--- a/lib/pq/table/import-pq-handler.tsx
+++ b/lib/pq/pq-criteria/import-pq-handler.tsx
@@ -1,7 +1,7 @@
"use client"
import { z } from "zod"
-import { createPq } from "../service" // PQ 생성 서버 액션
+import { createPqCriteria } from "../service" // PQ 생성 서버 액션
// PQ 데이터 검증을 위한 Zod 스키마
const pqItemSchema = z.object({
@@ -35,7 +35,7 @@ interface ProcessResult {
*/
export async function processFileImport(
jsonData: any[],
- projectId: number | null | undefined,
+ pqListId: number,
progressCallback?: (current: number, total: number) => void
): Promise<ProcessResult> {
// 결과 카운터 초기화
@@ -107,10 +107,7 @@ export async function processFileImport(
}
// PQ 생성 서버 액션 호출
- const createResult = await createPq({
- ...cleanedRow,
- projectId: projectId || 0
- });
+ const createResult = await createPqCriteria(pqListId, cleanedRow);
if (createResult.success) {
successCount++;
diff --git a/lib/pq/table/pq-excel-template.tsx b/lib/pq/pq-criteria/pq-excel-template.tsx
index aa8c1b3a..aa8c1b3a 100644
--- a/lib/pq/table/pq-excel-template.tsx
+++ b/lib/pq/pq-criteria/pq-excel-template.tsx
diff --git a/lib/pq/table/pq-table-column.tsx b/lib/pq/pq-criteria/pq-table-column.tsx
index b9317570..924d80c4 100644
--- a/lib/pq/table/pq-table-column.tsx
+++ b/lib/pq/pq-criteria/pq-table-column.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { ColumnDef } from "@tanstack/react-table"
-import { formatDate, formatDateTime } from "@/lib/utils"
+import { formatDate } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { DataTableRowAction } from "@/types/table"
@@ -10,13 +10,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
@@ -61,7 +55,7 @@ export function getColumns({
{
accessorKey: "groupName",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Group Name" />
+ <DataTableColumnHeaderSimple column={column} title="대분류" />
),
cell: ({ row }) => <div>{row.getValue("groupName")}</div>,
meta: {
@@ -71,10 +65,23 @@ export function getColumns({
minSize: 60,
size: 100,
},
+ {
+ accessorKey: "subGroupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="소분류" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("subGroupName") || "-"}</div>,
+ meta: {
+ excelHeader: "Sub Group Name"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 100,
+ },
{
accessorKey: "code",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Code" />
+ <DataTableColumnHeaderSimple column={column} title="일련번호" />
),
cell: ({ row }) => <div>{row.getValue("code")}</div>,
meta: {
@@ -87,7 +94,7 @@ export function getColumns({
{
accessorKey: "checkPoint",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Check Point" />
+ <DataTableColumnHeaderSimple column={column} title="PQ 항목" />
),
cell: ({ row }) => <div>{row.getValue("checkPoint")}</div>,
meta: {
@@ -101,7 +108,7 @@ export function getColumns({
{
accessorKey: "description",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Description" />
+ <DataTableColumnHeaderSimple column={column} title="설명" />
),
cell: ({ row }) => {
const text = row.getValue("description") as string
@@ -118,29 +125,71 @@ export function getColumns({
minSize: 180,
size: 180,
},
-
+ // {
+ // accessorKey: "remarks",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="SHI Comment" />
+ // ),
+ // cell: ({ row }) => {
+ // const text = row.getValue("remarks") as string
+ // return (
+ // <div style={{ whiteSpace: "pre-wrap" }}>
+ // {text || "-"}
+ // </div>
+ // )
+ // },
+ // meta: {
+ // excelHeader: "Remarks"
+ // },
+ // enableResizing: true,
+ // minSize: 180,
+ // size: 180,
+ // },
{
- accessorKey: "createdAt",
+ accessorKey: "inputFormat",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Created At" />
+ <DataTableColumnHeaderSimple column={column} title="협력업체 입력사항" />
),
- cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"),
+ cell: ({ row }) => {
+ const format = row.getValue("inputFormat") as string
+ const formatLabels = {
+ TEXT: "텍스트",
+ FILE: "파일",
+ EMAIL: "이메일",
+ PHONE: "전화번호",
+ NUMBER: "숫자",
+ "TEXT_FILE": "텍스트 + 파일"
+ }
+ return (
+ <Badge variant="outline">
+ {formatLabels[format as keyof typeof formatLabels] || format}
+ </Badge>
+ )
+ },
meta: {
- excelHeader: "created At"
+ excelHeader: "Input Format"
},
enableResizing: true,
+ minSize: 100,
+ size: 120,
+ },
+
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date, "ko-KR"),
+ enableResizing: true,
minSize: 180,
size: 180,
},
{
accessorKey: "updatedAt",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
),
- cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"),
- meta: {
- excelHeader: "updated At"
- },
+ cell: ({ cell }) => formatDate(cell.getValue() as Date, "ko-KR"),
enableResizing: true,
minSize: 180,
size: 180,
@@ -149,8 +198,6 @@ export function getColumns({
id: "actions",
enableHiding: false,
cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
diff --git a/lib/pq/table/pq-table-toolbar-actions.tsx b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
index 1790caf8..f168b83d 100644
--- a/lib/pq/table/pq-table-toolbar-actions.tsx
+++ b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
@@ -2,40 +2,39 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, FileDown, Upload } from "lucide-react"
-import { toast } from "sonner"
+// import { Download, FileDown } from "lucide-react"
+// import { toast } from "sonner"
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
+// import { exportTableToExcel } from "@/lib/export"
+// import { Button } from "@/components/ui/button"
+// import {
+// DropdownMenu,
+// DropdownMenuContent,
+// DropdownMenuItem,
+// DropdownMenuTrigger,
+// } from "@/components/ui/dropdown-menu"
import { DeletePqsDialog } from "./delete-pqs-dialog"
import { AddPqDialog } from "./add-pq-dialog"
import { PqCriterias } from "@/db/schema/pq"
-import { ImportPqButton } from "./import-pq-button"
-import { exportPqTemplate } from "./pq-excel-template"
+// import { ImportPqButton } from "./import-pq-button"
+// import { exportPqTemplate } from "./pq-excel-template"
interface PqTableToolbarActionsProps {
table: Table<PqCriterias>
- currentProjectId?: number
+ pqListId: number
}
export function PqTableToolbarActions({
table,
- currentProjectId
+ pqListId
}: PqTableToolbarActionsProps) {
- const [refreshKey, setRefreshKey] = React.useState(0)
- const isProjectSpecific = !!currentProjectId
+ // const [refreshKey, setRefreshKey] = React.useState(0)
- // Import 성공 후 테이블 갱신
- const handleImportSuccess = () => {
- setRefreshKey(prev => prev + 1)
- }
+ // // Import 성공 후 테이블 갱신
+ // const handleImportSuccess = () => {
+ // setRefreshKey(prev => prev + 1)
+ // }
return (
<div className="flex items-center gap-2">
@@ -48,16 +47,16 @@ export function PqTableToolbarActions({
/>
) : null}
- <AddPqDialog currentProjectId={currentProjectId} />
+ <AddPqDialog pqListId={pqListId} />
{/* Import 버튼 */}
- <ImportPqButton
- projectId={currentProjectId}
+ {/* <ImportPqButton
+ pqListId={pqListId}
onSuccess={handleImportSuccess}
- />
+ /> */}
{/* Export 드롭다운 메뉴 */}
- <DropdownMenu>
+ {/* <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Download className="size-4" aria-hidden="true" />
@@ -68,7 +67,7 @@ export function PqTableToolbarActions({
<DropdownMenuItem
onClick={() =>
exportTableToExcel(table, {
- filename: isProjectSpecific ? `project-${currentProjectId}-pq-criteria` : "general-pq-criteria",
+ filename: `pq-list-${pqListId}-criteria`,
excludeColumns: ["select", "actions"],
})
}
@@ -76,12 +75,12 @@ export function PqTableToolbarActions({
<FileDown className="mr-2 h-4 w-4" />
<span>현재 데이터 내보내기</span>
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => exportPqTemplate(isProjectSpecific)}>
+ <DropdownMenuItem onClick={() => exportPqTemplate()}>
<FileDown className="mr-2 h-4 w-4" />
- <span>{isProjectSpecific ? '프로젝트용' : '일반'} 템플릿 다운로드</span>
+ <span>템플릿 다운로드</span>
</DropdownMenuItem>
</DropdownMenuContent>
- </DropdownMenu>
+ </DropdownMenu> */}
</div>
)
} \ No newline at end of file
diff --git a/lib/pq/table/pq-table.tsx b/lib/pq/pq-criteria/pq-table.tsx
index 99365ad5..e0e3dee5 100644
--- a/lib/pq/table/pq-table.tsx
+++ b/lib/pq/pq-criteria/pq-table.tsx
@@ -9,7 +9,7 @@ import type {
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
-import { getPQs } from "../service"
+import { getPQsByListId } from "../service"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { PqCriterias } from "@/db/schema/pq"
import { DeletePqsDialog } from "./delete-pqs-dialog"
@@ -18,13 +18,13 @@ import { getColumns } from "./pq-table-column"
import { UpdatePqSheet } from "./update-pq-sheet"
interface DocumentListTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getPQs>>]>
- currentProjectId?: number
+ promises: Promise<[Awaited<ReturnType<typeof getPQsByListId>>]>
+ pqListId: number
}
export function PqsTable({
promises,
- currentProjectId
+ pqListId
}: DocumentListTableProps) {
// 1) 데이터를 가져옴 (server component -> use(...) pattern)
const [{ data, pageCount }] = React.use(promises)
@@ -105,7 +105,7 @@ export function PqsTable({
filterFields={advancedFilterFields}
shallow={false}
>
- <PqTableToolbarActions table={table} currentProjectId={currentProjectId}/>
+ <PqTableToolbarActions table={table} pqListId={pqListId}/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/pq/table/update-pq-sheet.tsx b/lib/pq/pq-criteria/update-pq-sheet.tsx
index 4da3c264..245627e6 100644
--- a/lib/pq/table/update-pq-sheet.tsx
+++ b/lib/pq/pq-criteria/update-pq-sheet.tsx
@@ -1,264 +1,330 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Save } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-import { useRouter } from "next/navigation"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-
-import { modifyPq } from "../service"
-import { groupOptions } from "./add-pq-dialog"
-
-// PQ 수정을 위한 Zod 스키마 정의
-const updatePqSchema = z.object({
- code: z.string().min(1, "Code is required"),
- checkPoint: z.string().min(1, "Check point is required"),
- groupName: z.string().min(1, "Group is required"),
- description: z.string().optional(),
- remarks: z.string().optional()
-});
-
-type UpdatePqSchema = z.infer<typeof updatePqSchema>;
-
-interface UpdatePqSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- pq: {
- id: number;
- code: string;
- checkPoint: string;
- description: string | null;
- remarks: string | null;
- groupName: string | null;
- } | null
-}
-
-export function UpdatePqSheet({ pq, ...props }: UpdatePqSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const router = useRouter()
-
- const form = useForm<UpdatePqSchema>({
- resolver: zodResolver(updatePqSchema),
- defaultValues: {
- code: pq?.code ?? "",
- checkPoint: pq?.checkPoint ?? "",
- groupName: pq?.groupName ?? groupOptions[0],
- description: pq?.description ?? "",
- remarks: pq?.remarks ?? "",
- },
- })
-
- // 폼 초기화 (pq가 변경될 때)
- React.useEffect(() => {
- if (pq) {
- form.reset({
- code: pq.code,
- checkPoint: pq.checkPoint,
- groupName: pq.groupName ?? groupOptions[0],
- description: pq.description ?? "",
- remarks: pq.remarks ?? "",
- });
- }
- }, [pq, form]);
-
- function onSubmit(input: UpdatePqSchema) {
- startUpdateTransition(async () => {
- if (!pq) return
-
- const result = await modifyPq({
- id: pq.id,
- ...input,
- })
-
- if (!result.success && 'error' in result) {
- toast.error(result.error)
- } else {
- toast.error("Failed to update PQ criteria")
- }
-
- form.reset()
- props.onOpenChange?.(false)
- toast.success("PQ criteria updated successfully")
- router.refresh()
- })
- }
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>Update PQ Criteria</SheetTitle>
- <SheetDescription>
- Update the PQ criteria details and save the changes
- </SheetDescription>
- </SheetHeader>
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
- >
- {/* Code 필드 */}
- <FormField
- control={form.control}
- name="code"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
- <FormControl>
- <Input
- placeholder="예: 1-1, A.2.3"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Check Point 필드 */}
- <FormField
- control={form.control}
- name="checkPoint"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
- <FormControl>
- <Input
- placeholder="검증 항목을 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Group Name 필드 (Select) */}
- <FormField
- control={form.control}
- name="groupName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- value={field.value}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="그룹을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {groupOptions.map((group) => (
- <SelectItem key={group} value={group}>
- {group}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Description 필드 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Description</FormLabel>
- <FormControl>
- <Textarea
- placeholder="상세 설명을 입력하세요"
- className="min-h-[120px] whitespace-pre-wrap"
- {...field}
- value={field.value || ""}
- />
- </FormControl>
- <FormDescription>
- 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Remarks 필드 */}
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Remarks</FormLabel>
- <FormControl>
- <Textarea
- placeholder="비고 사항을 입력하세요"
- className="min-h-[80px]"
- {...field}
- value={field.value || ""}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button
- type="button"
- variant="outline"
- onClick={() => form.reset()}
- >
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isUpdatePending}>
- {isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- <Save className="mr-2 size-4" /> Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Save } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ // SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import { updatePqCriteria } from "../service"
+import { groupOptions } from "./add-pq-dialog"
+import { Checkbox } from "@/components/ui/checkbox"
+
+// PQ 수정을 위한 Zod 스키마 정의
+const updatePqSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional(),
+ inputFormat: z.string().default("TEXT"),
+
+ subGroupName: z.string().optional(),
+});
+
+type UpdatePqSchema = z.infer<typeof updatePqSchema>;
+
+// 입력 형식 옵션
+const inputFormatOptions = [
+ { value: "TEXT", label: "텍스트" },
+ { value: "FILE", label: "파일" },
+ { value: "EMAIL", label: "이메일" },
+ { value: "PHONE", label: "전화번호" },
+ { value: "NUMBER", label: "숫자" },
+ { value: "TEXT_FILE", label: "텍스트 + 파일" }
+];
+
+interface UpdatePqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ pq: {
+ id: number;
+ code: string;
+ checkPoint: string;
+ description: string | null;
+ remarks: string | null;
+ groupName: string | null;
+ inputFormat: string;
+
+ subGroupName: string | null;
+ } | null
+}
+
+export function UpdatePqSheet({ pq, ...props }: UpdatePqSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const router = useRouter()
+
+ const form = useForm<UpdatePqSchema>({
+ resolver: zodResolver(updatePqSchema),
+ defaultValues: {
+ code: pq?.code ?? "",
+ checkPoint: pq?.checkPoint ?? "",
+ groupName: pq?.groupName ?? groupOptions[0],
+ description: pq?.description ?? "",
+ remarks: pq?.remarks ?? "",
+ inputFormat: pq?.inputFormat ?? "TEXT",
+
+ subGroupName: pq?.subGroupName ?? "",
+ },
+ })
+
+ // 폼 초기화 (pq가 변경될 때)
+ React.useEffect(() => {
+ if (pq) {
+ form.reset({
+ code: pq.code,
+ checkPoint: pq.checkPoint,
+ groupName: pq.groupName ?? groupOptions[0],
+ description: pq.description ?? "",
+ remarks: pq.remarks ?? "",
+ inputFormat: pq.inputFormat ?? "TEXT",
+
+ subGroupName: pq.subGroupName ?? "",
+ });
+ }
+ }, [pq, form]);
+
+ function onSubmit(input: UpdatePqSchema) {
+ startUpdateTransition(async () => {
+ if (!pq) return
+
+ const result = await updatePqCriteria(pq.id, input)
+
+ if (!result.success) {
+ toast.error(result.message || "PQ 항목 수정에 실패했습니다")
+ return
+ }
+
+ toast.success(result.message || "PQ 항목이 성공적으로 수정되었습니다")
+ form.reset()
+ props.onOpenChange?.(false)
+ router.refresh()
+ })
+ }
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update PQ Criteria</SheetTitle>
+ <SheetDescription>
+ Update the PQ criteria details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="검증 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Group Name 필드 (Select) */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* Sub Group Name 필드 */}
+ <FormField
+ control={form.control}
+ name="subGroupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Sub Group Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="서브 그룹명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Input Format 필드 */}
+ <FormField
+ control={form.control}
+ name="inputFormat"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입력 형식</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입력 형식을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {inputFormatOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Required 체크박스 */}
+
+
+ {/* Description 필드 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 설명을 입력하세요"
+ className="min-h-[120px] whitespace-pre-wrap"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => form.reset()}
+ >
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Save className="mr-2 size-4" /> Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
index 03045537..94b33ab4 100644
--- a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
@@ -1,69 +1,69 @@
-"use client"
-
-import * as React from "react"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-
-interface CancelInvestigationDialogProps {
- isOpen: boolean
- onClose: () => void
- onConfirm: () => Promise<void>
- selectedCount: number
-}
-
-export function CancelInvestigationDialog({
- isOpen,
- onClose,
- onConfirm,
- selectedCount,
-}: CancelInvestigationDialogProps) {
- const [isPending, setIsPending] = React.useState(false)
-
- async function handleConfirm() {
- setIsPending(true)
- try {
- await onConfirm()
- } finally {
- setIsPending(false)
- }
- }
-
- return (
- <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>실사 의뢰 취소</DialogTitle>
- <DialogDescription>
- 선택한 {selectedCount}개 협력업체의 실사 의뢰를 취소하시겠습니까?
- 계획 상태인 실사만 취소할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={onClose}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- variant="destructive"
- onClick={handleConfirm}
- disabled={isPending}
- >
- {isPending ? "처리 중..." : "실사 의뢰 취소"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+interface CancelInvestigationDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: () => Promise<void>
+ selectedCount: number
+}
+
+export function CancelInvestigationDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ selectedCount,
+}: CancelInvestigationDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+
+ async function handleConfirm() {
+ setIsPending(true)
+ try {
+ await onConfirm()
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>실사 의뢰 취소</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedCount}개 협력업체의 실사 의뢰를 취소하시겠습니까?
+ 계획 상태인 실사만 취소할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleConfirm}
+ disabled={isPending}
+ >
+ {isPending ? "처리 중..." : "실사 의뢰 취소"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
new file mode 100644
index 00000000..4df7a7ec
--- /dev/null
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -0,0 +1,217 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { CalendarIcon, Loader } from "lucide-react"
+import { format } from "date-fns"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { z } from "zod"
+
+// Validation schema for editing investigation
+const editInvestigationSchema = z.object({
+ confirmedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+ evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(),
+ investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
+})
+
+type EditInvestigationSchema = z.infer<typeof editInvestigationSchema>
+
+interface EditInvestigationDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ investigation: {
+ id: number
+ confirmedAt?: Date | null
+ evaluationResult?: string | null
+ investigationNotes?: string | null
+ } | null
+ onSubmit: (data: EditInvestigationSchema) => Promise<void>
+}
+
+export function EditInvestigationDialog({
+ isOpen,
+ onClose,
+ investigation,
+ onSubmit,
+}: EditInvestigationDialogProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<EditInvestigationSchema>({
+ resolver: zodResolver(editInvestigationSchema),
+ defaultValues: {
+ confirmedAt: investigation?.confirmedAt || undefined,
+ evaluationResult: investigation?.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
+ investigationNotes: investigation?.investigationNotes || "",
+ },
+ })
+
+ // Reset form when investigation changes
+ React.useEffect(() => {
+ if (investigation) {
+ form.reset({
+ confirmedAt: investigation.confirmedAt || undefined,
+ evaluationResult: investigation.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
+ investigationNotes: investigation.investigationNotes || "",
+ })
+ }
+ }, [investigation, form])
+
+ const handleSubmit = async (values: EditInvestigationSchema) => {
+ startTransition(async () => {
+ try {
+ await onSubmit(values)
+ toast.success("실사 정보가 업데이트되었습니다!")
+ onClose()
+ } catch (error) {
+ console.error("실사 정보 업데이트 오류:", error)
+ toast.error("실사 정보 업데이트 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>실사 정보 수정</DialogTitle>
+ <DialogDescription>
+ 구매자체평가 실사 정보를 수정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ {/* 실사 확정일 */}
+ <FormField
+ control={form.control}
+ name="confirmedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 확정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 결과 */}
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 결과</FormLabel>
+ <FormControl>
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 결과를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="APPROVED">승인</SelectItem>
+ <SelectItem value="SUPPLEMENT">보완</SelectItem>
+ <SelectItem value="REJECTED">불가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* QM 의견 */}
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사에 대한 QM 의견을 입력하세요..."
+ {...field}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={onClose} disabled={isPending}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ 저장
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/feature-flags-provider.tsx b/lib/pq/pq-review-table-new/feature-flags-provider.tsx
index 81131894..615377d6 100644
--- a/lib/pq/pq-review-table-new/feature-flags-provider.tsx
+++ b/lib/pq/pq-review-table-new/feature-flags-provider.tsx
@@ -1,108 +1,108 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/pq/pq-review-table-new/pq-container.tsx b/lib/pq/pq-review-table-new/pq-container.tsx
index ebe46809..01b7aab1 100644
--- a/lib/pq/pq-review-table-new/pq-container.tsx
+++ b/lib/pq/pq-review-table-new/pq-container.tsx
@@ -1,151 +1,151 @@
-"use client"
-
-import { useState, useEffect, useCallback, useRef } from "react"
-import { useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-import { getPQSubmissions } from "../service"
-import { PQSubmissionsTable } from "./vendors-table"
-import { PQFilterSheet } from "./pq-filter-sheet"
-
-interface PQContainerProps {
- // Promise.all로 감싼 promises를 받음
- promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]>
- // 컨테이너 클래스명 (옵션)
- className?: string
-}
-
-export default function PQContainer({
- promises,
- className
-}: PQContainerProps) {
- const searchParams = useSearchParams()
-
- // Whether the filter panel is open
- const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false)
-
- // Container wrapper의 위치를 측정하기 위한 ref
- const containerRef = useRef<HTMLDivElement>(null)
- const [containerTop, setContainerTop] = useState(0)
-
- // Container 위치 측정 함수 - top만 측정
- const updateContainerBounds = useCallback(() => {
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- setContainerTop(rect.top)
- }
- }, [])
-
- // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트
- useEffect(() => {
- updateContainerBounds()
-
- const handleResize = () => {
- updateContainerBounds()
- }
-
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', updateContainerBounds)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', updateContainerBounds)
- }
- }, [updateContainerBounds])
-
- // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달
- const handleSearch = () => {
- // Close the panel after search
- setIsFilterPanelOpen(false)
- }
-
- // Get active filter count for UI display (서버 사이드 필터만 계산)
- const getActiveFilterCount = () => {
- try {
- // 새로운 이름 우선, 기존 이름도 지원
- const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
- }
- }
-
- // Filter panel width
- const FILTER_PANEL_WIDTH = 400;
-
- return (
- <>
- {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${containerTop}px`,
- height: `calc(100vh - ${containerTop}px)`
- }}
- >
- {/* Filter Content */}
- <div className="h-full">
- <PQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false} // 로딩 상태 제거
- />
- </div>
- </div>
-
- {/* Main Content Container */}
- <div
- ref={containerRef}
- className={cn("relative w-full overflow-hidden", className)}
- >
- <div className="flex w-full h-full">
- {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */}
- <div
- className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
- }}
- >
- {/* Header Bar */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {
- isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>
- }
- {getActiveFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveFilterCount()}
- </span>
- )}
- </Button>
- </div>
- </div>
-
- {/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
- <div className="h-full w-full">
- {/* Promise를 직접 전달 - Items와 동일한 패턴 */}
- <PQSubmissionsTable promises={promises} />
- </div>
- </div>
- </div>
- </div>
- </div>
- </>
- )
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { getPQSubmissions } from "../service"
+import { PQSubmissionsTable } from "./vendors-table"
+import { PQFilterSheet } from "./pq-filter-sheet"
+
+interface PQContainerProps {
+ // Promise.all로 감싼 promises를 받음
+ promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]>
+ // 컨테이너 클래스명 (옵션)
+ className?: string
+}
+
+export default function PQContainer({
+ promises,
+ className
+}: PQContainerProps) {
+ const searchParams = useSearchParams()
+
+ // Whether the filter panel is open
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false)
+
+ // Container wrapper의 위치를 측정하기 위한 ref
+ const containerRef = useRef<HTMLDivElement>(null)
+ const [containerTop, setContainerTop] = useState(0)
+
+ // Container 위치 측정 함수 - top만 측정
+ const updateContainerBounds = useCallback(() => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect()
+ setContainerTop(rect.top)
+ }
+ }, [])
+
+ // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트
+ useEffect(() => {
+ updateContainerBounds()
+
+ const handleResize = () => {
+ updateContainerBounds()
+ }
+
+ window.addEventListener('resize', handleResize)
+ window.addEventListener('scroll', updateContainerBounds)
+
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ window.removeEventListener('scroll', updateContainerBounds)
+ }
+ }, [updateContainerBounds])
+
+ // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달
+ const handleSearch = () => {
+ // Close the panel after search
+ setIsFilterPanelOpen(false)
+ }
+
+ // Get active filter count for UI display (서버 사이드 필터만 계산)
+ const getActiveFilterCount = () => {
+ try {
+ // 새로운 이름 우선, 기존 이름도 지원
+ const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch (e) {
+ return 0
+ }
+ }
+
+ // Filter panel width
+ const FILTER_PANEL_WIDTH = 400;
+
+ return (
+ <>
+ {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${containerTop}px`,
+ height: `calc(100vh - ${containerTop}px)`
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <PQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false} // 로딩 상태 제거
+ />
+ </div>
+ </div>
+
+ {/* Main Content Container */}
+ <div
+ ref={containerRef}
+ className={cn("relative w-full overflow-hidden", className)}
+ >
+ <div className="flex w-full h-full">
+ {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */}
+ <div
+ className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
+ }}
+ >
+ {/* Header Bar */}
+ <div className="flex items-center justify-between p-4 bg-background shrink-0">
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {
+ isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>
+ }
+ {getActiveFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* Table Content Area */}
+ <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
+ <div className="h-full w-full">
+ {/* Promise를 직접 전달 - Items와 동일한 패턴 */}
+ <PQSubmissionsTable promises={promises} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/pq-filter-sheet.tsx b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
index 979f25a2..ff1b890b 100644
--- a/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
+++ b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx
@@ -1,651 +1,651 @@
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CalendarIcon, ChevronRight, Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { useTranslation } from '@/i18n/client'
-import { getFiltersStateParser } from "@/lib/parsers"
-import { DateRangePicker } from "@/components/date-range-picker"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// PQ 필터 스키마 정의
-const pqFilterSchema = z.object({
- requesterName: z.string().optional(),
- pqNumber: z.string().optional(),
- vendorName: z.string().optional(),
- status: z.string().optional(),
- evaluationResult: z.string().optional(),
- createdAtRange: z.object({
- from: z.date().optional(),
- to: z.date().optional(),
- }).optional(),
-})
-
-// PQ 상태 옵션 정의
-const pqStatusOptions = [
- { value: "REQUESTED", label: "요청됨" },
- { value: "IN_PROGRESS", label: "진행 중" },
- { value: "SUBMITTED", label: "제출됨" },
- { value: "APPROVED", label: "승인됨" },
- { value: "REJECTED", label: "거부됨" },
-]
-
-// 평가 결과 옵션 정의
-const evaluationResultOptions = [
- { value: "APPROVED", label: "승인" },
- { value: "SUPPLEMENT", label: "보완" },
- { value: "REJECTED", label: "불가" },
-]
-
-type PQFilterFormValues = z.infer<typeof pqFilterSchema>
-
-interface PQFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onSearch?: () => void;
- isLoading?: boolean;
-}
-
-export function PQFilterSheet({
- isOpen,
- onClose,
- onSearch,
- isLoading = false
-}: PQFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
- const lng = params ? (params.lng as string) : 'ko';
- const { t } = useTranslation(lng);
-
- const [isPending, startTransition] = useTransition()
-
- // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
- const [isInitializing, setIsInitializing] = useState(false)
- // 마지막으로 적용된 필터를 추적하기 위한 ref
- const lastAppliedFilters = useRef<string>("")
-
- // nuqs로 URL 상태 관리 - 파라미터명을 'pqBasicFilters'로 변경
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
-
- // joinOperator 설정
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
-
- // 현재 URL의 페이지 파라미터도 가져옴
- const [page, setPage] = useQueryState("page", { defaultValue: "1" })
-
- // 폼 상태 초기화
- const form = useForm<PQFilterFormValues>({
- resolver: zodResolver(pqFilterSchema),
- defaultValues: {
- requesterName: "",
- pqNumber: "",
- vendorName: "",
- status: "",
- evaluationResult: "",
- createdAtRange: {
- from: undefined,
- to: undefined,
- },
- },
- })
-
- // URL 필터에서 초기 폼 상태 설정
- useEffect(() => {
- // 현재 필터를 문자열로 직렬화
- const currentFiltersString = JSON.stringify(filters);
-
- // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id === "createdAt" && Array.isArray(filter.value) && filter.value.length > 0) {
- formValues.createdAtRange = {
- from: filter.value[0] ? new Date(filter.value[0]) : undefined,
- to: filter.value[1] ? new Date(filter.value[1]) : undefined,
- };
- formUpdated = true;
- } else if (filter.id in formValues) {
- // @ts-ignore - 동적 필드 접근
- formValues[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen])
-
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
-
-// 폼 제출 핸들러 - 수동 URL 업데이트 버전
-async function onSubmit(data: PQFilterFormValues) {
- // 초기화 중이면 제출 방지
- if (isInitializing) return;
-
- startTransition(async () => {
- try {
- // 필터 배열 생성
- const newFilters = []
-
- if (data.requesterName?.trim()) {
- newFilters.push({
- id: "requesterName",
- value: data.requesterName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.pqNumber?.trim()) {
- newFilters.push({
- id: "pqNumber",
- value: data.pqNumber.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.vendorName?.trim()) {
- newFilters.push({
- id: "vendorName",
- value: data.vendorName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- if (data.evaluationResult?.trim()) {
- newFilters.push({
- id: "evaluationResult",
- value: data.evaluationResult.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 생성일 범위 추가
- if (data.createdAtRange?.from) {
- newFilters.push({
- id: "createdAt",
- value: [
- data.createdAtRange.from.toISOString().split('T')[0],
- data.createdAtRange.to ? data.createdAtRange.to.toISOString().split('T')[0] : undefined
- ].filter(Boolean),
- type: "date",
- operator: "isBetween",
- rowId: generateId()
- })
- }
-
- // 수동으로 URL 업데이트 (nuqs 대신)
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 기존 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('pqBasicFilters');
- params.delete('basicJoinOperator');
- params.delete('pqBasicJoinOperator');
- params.delete('page');
-
- // 새로운 필터 추가
- if (newFilters.length > 0) {
- params.set('basicFilters', JSON.stringify(newFilters));
- params.set('basicJoinOperator', joinOperator);
- }
-
- // 페이지를 1로 설정
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("New URL:", newUrl);
-
- // 페이지 완전 새로고침으로 서버 렌더링 강제
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 업데이트
- lastAppliedFilters.current = JSON.stringify(newFilters);
-
- // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
- if (onSearch) {
- console.log("Calling onSearch...");
- onSearch();
- }
-
- console.log("=== PQ Filter Submit Complete ===");
- } catch (error) {
- console.error("PQ 필터 적용 오류:", error);
- }
- })
-}
-
- // 필터 초기화 핸들러
- // 필터 초기화 핸들러
- async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- requesterName: "",
- pqNumber: "",
- vendorName: "",
- status: "",
- evaluationResult: "",
- createdAtRange: { from: undefined, to: undefined },
- });
-
- console.log("=== PQ Filter Reset Debug ===");
- console.log("Current URL before reset:", window.location.href);
-
- // 수동으로 URL 초기화
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('pqBasicFilters');
- params.delete('basicJoinOperator');
- params.delete('pqBasicJoinOperator');
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("Reset URL:", newUrl);
-
- // 페이지 완전 새로고침
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 초기화
- lastAppliedFilters.current = "";
-
- console.log("PQ 필터 초기화 완료");
- setIsInitializing(false);
- } catch (error) {
- console.error("PQ 필터 초기화 오류:", error);
- setIsInitializing(false);
- }
- }
-
- // Don't render if not open (for side panel use)
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* Filter Panel Header */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">PQ 검색 필터</h3>
- <div className="flex items-center gap-2">
- {getActiveFilterCount() > 0 && (
- <Badge variant="secondary" className="px-2 py-1">
- {getActiveFilterCount()}개 필터 적용됨
- </Badge>
- )}
- </div>
- </div>
-
- {/* Join Operator Selection */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-4 pt-2">
- {/* 요청자명 */}
- <FormField
- control={form.control}
- name="requesterName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>요청자명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="요청자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("requesterName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* PQ 번호 */}
- <FormField
- control={form.control}
- name="pqNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel>PQ 번호</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="PQ 번호 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("pqNumber", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 협력업체명 */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>협력업체명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="협력업체명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("vendorName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* PQ 상태 */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>PQ 상태</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="PQ 상태 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {pqStatusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 평가 결과 */}
- <FormField
- control={form.control}
- name="evaluationResult"
- render={({ field }) => (
- <FormItem>
- <FormLabel>평가 결과</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="평가 결과 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("evaluationResult", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {evaluationResultOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* PQ 생성일 */}
- <FormField
- control={form.control}
- name="createdAtRange"
- render={({ field }) => (
- <FormItem>
- <FormLabel>PQ 생성일</FormLabel>
- <FormControl>
- <div className="relative">
- <DateRangePicker
- triggerSize="default"
- triggerClassName="w-full bg-white"
- align="start"
- showClearButton={true}
- placeholder="PQ 생성일 범위를 선택하세요"
- value={field.value || undefined}
- onChange={field.onChange}
- disabled={isInitializing}
- />
- {(field.value?.from || field.value?.to) && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-10 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("createdAtRange", { from: undefined, to: undefined });
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* Fixed buttons at bottom */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
- className="px-4"
- >
- 초기화
- </Button>
- <Button
- type="submit"
- variant="samsung"
- disabled={isPending || isLoading || isInitializing}
- className="px-4"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? "조회 중..." : "조회"}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { CalendarIcon, ChevronRight, Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+import { DateRangePicker } from "@/components/date-range-picker"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// PQ 필터 스키마 정의
+const pqFilterSchema = z.object({
+ requesterName: z.string().optional(),
+ pqNumber: z.string().optional(),
+ vendorName: z.string().optional(),
+ status: z.string().optional(),
+ evaluationResult: z.string().optional(),
+ createdAtRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// PQ 상태 옵션 정의
+const pqStatusOptions = [
+ { value: "REQUESTED", label: "요청됨" },
+ { value: "IN_PROGRESS", label: "진행 중" },
+ { value: "SUBMITTED", label: "제출됨" },
+ { value: "APPROVED", label: "승인됨" },
+ { value: "REJECTED", label: "거부됨" },
+]
+
+// 평가 결과 옵션 정의
+const evaluationResultOptions = [
+ { value: "APPROVED", label: "승인" },
+ { value: "SUPPLEMENT", label: "보완" },
+ { value: "REJECTED", label: "불가" },
+]
+
+type PQFilterFormValues = z.infer<typeof pqFilterSchema>
+
+interface PQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+export function PQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: PQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'pqBasicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<PQFilterFormValues>({
+ resolver: zodResolver(pqFilterSchema),
+ defaultValues: {
+ requesterName: "",
+ pqNumber: "",
+ vendorName: "",
+ status: "",
+ evaluationResult: "",
+ createdAtRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "createdAt" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.createdAtRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // @ts-ignore - 동적 필드 접근
+ formValues[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen])
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+// 폼 제출 핸들러 - 수동 URL 업데이트 버전
+async function onSubmit(data: PQFilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.requesterName?.trim()) {
+ newFilters.push({
+ id: "requesterName",
+ value: data.requesterName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.pqNumber?.trim()) {
+ newFilters.push({
+ id: "pqNumber",
+ value: data.pqNumber.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.vendorName?.trim()) {
+ newFilters.push({
+ id: "vendorName",
+ value: data.vendorName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.evaluationResult?.trim()) {
+ newFilters.push({
+ id: "evaluationResult",
+ value: data.evaluationResult.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // 생성일 범위 추가
+ if (data.createdAtRange?.from) {
+ newFilters.push({
+ id: "createdAt",
+ value: [
+ data.createdAtRange.from.toISOString().split('T')[0],
+ data.createdAtRange.to ? data.createdAtRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean),
+ type: "date",
+ operator: "isBetween",
+ rowId: generateId()
+ })
+ }
+
+ // 수동으로 URL 업데이트 (nuqs 대신)
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 기존 필터 관련 파라미터 제거
+ params.delete('basicFilters');
+ params.delete('pqBasicFilters');
+ params.delete('basicJoinOperator');
+ params.delete('pqBasicJoinOperator');
+ params.delete('page');
+
+ // 새로운 필터 추가
+ if (newFilters.length > 0) {
+ params.set('basicFilters', JSON.stringify(newFilters));
+ params.set('basicJoinOperator', joinOperator);
+ }
+
+ // 페이지를 1로 설정
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ console.log("New URL:", newUrl);
+
+ // 페이지 완전 새로고침으로 서버 렌더링 강제
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ if (onSearch) {
+ console.log("Calling onSearch...");
+ onSearch();
+ }
+
+ console.log("=== PQ Filter Submit Complete ===");
+ } catch (error) {
+ console.error("PQ 필터 적용 오류:", error);
+ }
+ })
+}
+
+ // 필터 초기화 핸들러
+ // 필터 초기화 핸들러
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ requesterName: "",
+ pqNumber: "",
+ vendorName: "",
+ status: "",
+ evaluationResult: "",
+ createdAtRange: { from: undefined, to: undefined },
+ });
+
+ console.log("=== PQ Filter Reset Debug ===");
+ console.log("Current URL before reset:", window.location.href);
+
+ // 수동으로 URL 초기화
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 필터 관련 파라미터 제거
+ params.delete('basicFilters');
+ params.delete('pqBasicFilters');
+ params.delete('basicJoinOperator');
+ params.delete('pqBasicJoinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ console.log("Reset URL:", newUrl);
+
+ // 페이지 완전 새로고침
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("PQ 필터 초기화 완료");
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("PQ 필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
+ {/* Filter Panel Header */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">PQ 검색 필터</h3>
+ <div className="flex items-center gap-2">
+ {getActiveFilterCount() > 0 && (
+ <Badge variant="secondary" className="px-2 py-1">
+ {getActiveFilterCount()}개 필터 적용됨
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* Join Operator Selection */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+ {/* 요청자명 */}
+ <FormField
+ control={form.control}
+ name="requesterName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청자명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="요청자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("requesterName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PQ 번호 */}
+ <FormField
+ control={form.control}
+ name="pqNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 번호</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="PQ 번호 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("pqNumber", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 협력업체명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>협력업체명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="협력업체명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PQ 상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 상태</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="PQ 상태 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {pqStatusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 결과 */}
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 결과</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="평가 결과 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("evaluationResult", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {evaluationResultOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PQ 생성일 */}
+ <FormField
+ control={form.control}
+ name="createdAtRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 생성일</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder="PQ 생성일 범위를 선택하세요"
+ value={field.value || undefined}
+ onChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("createdAtRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ 초기화
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? "조회 중..." : "조회"}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
index d5588be4..6cbb885f 100644
--- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -1,331 +1,338 @@
-"use client"
-
-import * as React from "react"
-import { CalendarIcon } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { format } from "date-fns"
-import { z } from "zod"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Calendar } from "@/components/ui/calendar"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { UserCombobox } from "./user-combobox"
-import { getQMManagers } from "@/lib/pq/service"
-
-// QM 사용자 타입
-interface QMUser {
- id: number
- name: string
- email: string
- department?: string
-}
-
-const requestInvestigationFormSchema = z.object({
- evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"], {
- required_error: "평가 유형을 선택해주세요.",
- }),
- qmManagerId: z.number({
- required_error: "QM 담당자를 선택해주세요.",
- }),
- forecastedAt: z.date({
- required_error: "실사 예정일을 선택해주세요.",
- }),
- investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."),
- investigationMethod: z.string().optional(),
- investigationNotes: z.string().optional(),
-})
-
-type RequestInvestigationFormValues = z.infer<typeof requestInvestigationFormSchema>
-
-interface RequestInvestigationDialogProps {
- isOpen: boolean
- onClose: () => void
- onSubmit: (data: {
- evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT",
- qmManagerId: number,
- forecastedAt: Date,
- investigationAddress: string,
- investigationMethod?: string,
- investigationNotes?: string
- }) => Promise<void>
- selectedCount: number
- // 선택된 행에서 가져온 초기값
- initialData?: {
- evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT",
- qmManagerId?: number,
- forecastedAt?: Date,
- investigationAddress?: string,
- investigationMethod?: string,
- investigationNotes?: string
- }
-}
-
-export function RequestInvestigationDialog({
- isOpen,
- onClose,
- onSubmit,
- selectedCount,
- initialData,
-}: RequestInvestigationDialogProps) {
- const [isPending, setIsPending] = React.useState(false)
- const [qmManagers, setQMManagers] = React.useState<QMUser[]>([])
- const [isLoadingManagers, setIsLoadingManagers] = React.useState(false)
-
- // form 객체 생성 시 initialData 활용
- const form = useForm<RequestInvestigationFormValues>({
- resolver: zodResolver(requestInvestigationFormSchema),
- defaultValues: {
- evaluationType: initialData?.evaluationType || "SITE_AUDIT",
- qmManagerId: initialData?.qmManagerId || undefined,
- forecastedAt: initialData?.forecastedAt || undefined,
- investigationAddress: initialData?.investigationAddress || "",
- investigationMethod: initialData?.investigationMethod || "",
- investigationNotes: initialData?.investigationNotes || "",
- },
- })
-
- // Dialog가 열릴 때마다 초기값으로 폼 재설정
- React.useEffect(() => {
- if (isOpen) {
- form.reset({
- evaluationType: initialData?.evaluationType || "SITE_AUDIT",
- qmManagerId: initialData?.qmManagerId || undefined,
- forecastedAt: initialData?.forecastedAt || undefined,
- investigationAddress: initialData?.investigationAddress || "",
- investigationMethod: initialData?.investigationMethod || "",
- investigationNotes: initialData?.investigationNotes || "",
- });
- }
- }, [isOpen, initialData, form]);
-
- // Dialog가 열릴 때 QM 담당자 목록 로드
- React.useEffect(() => {
- if (isOpen && qmManagers.length === 0) {
- const loadQMManagers = async () => {
- setIsLoadingManagers(true)
- try {
- const result = await getQMManagers()
- if (result.success && result.data) {
- setQMManagers(result.data)
- }
- } catch (error) {
- console.error("QM 담당자 로드 오류:", error)
- } finally {
- setIsLoadingManagers(false)
- }
- }
-
- loadQMManagers()
- }
- }, [isOpen, qmManagers.length])
-
- async function handleSubmit(data: RequestInvestigationFormValues) {
- setIsPending(true)
- try {
- await onSubmit(data)
- } finally {
- setIsPending(false)
- form.reset()
- }
- }
-
- return (
- <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>실사 의뢰</DialogTitle>
- <DialogDescription>
- {selectedCount}개 협력업체에 대한 실사를 의뢰합니다. 실사 관련 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
- <FormField
- control={form.control}
- name="evaluationType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>평가 유형</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- disabled={isPending}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="평가 유형을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem>
- <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="qmManagerId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>QM 담당자</FormLabel>
- <FormControl>
- <UserCombobox
- users={qmManagers}
- value={field.value}
- onChange={field.onChange}
- placeholder={isLoadingManagers ? "담당자 로딩 중..." : "담당자 선택..."}
- disabled={isPending || isLoadingManagers}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="forecastedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실사 예정일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant={"outline"}
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- disabled={isPending}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>실사 예정일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) => date < new Date()}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="investigationAddress"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 장소</FormLabel>
- <FormControl>
- <Textarea
- placeholder="실사가 진행될 주소를 입력하세요"
- {...field}
- disabled={isPending}
- className="min-h-[60px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="investigationMethod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 방법 (선택사항)</FormLabel>
- <FormControl>
- <Input
- placeholder="실사 방법을 입력하세요"
- {...field}
- disabled={isPending}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="investigationNotes"
- render={({ field }) => (
- <FormItem>
- <FormLabel>특이사항 (선택사항)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="실사 관련 특이사항을 입력하세요"
- className="resize-none min-h-[60px]"
- {...field}
- disabled={isPending}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={onClose}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isPending || isLoadingManagers}>
- {isPending ? "처리 중..." : "실사 의뢰"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { CalendarIcon } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { format } from "date-fns"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { UserCombobox } from "./user-combobox"
+import { getQMManagers } from "@/lib/pq/service"
+
+// QM 사용자 타입
+interface QMUser {
+ id: number
+ name: string
+ email: string
+ department?: string
+}
+
+const requestInvestigationFormSchema = z.object({
+ evaluationType: z.enum([
+ "PURCHASE_SELF_EVAL", // 구매자체평가
+ "DOCUMENT_EVAL", // 서류평가
+ // "PRODUCT_INSPECTION", // 제품검사평가
+ // "SITE_VISIT_EVAL" // 방문실사평가
+ ], {
+ required_error: "평가 유형을 선택해주세요.",
+ }),
+ qmManagerId: z.number({
+ required_error: "QM 담당자를 선택해주세요.",
+ }),
+ forecastedAt: z.date({
+ required_error: "실사 예정일을 선택해주세요.",
+ }),
+ investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."),
+ investigationMethod: z.string().optional(),
+ investigationNotes: z.string().optional(),
+})
+
+type RequestInvestigationFormValues = z.infer<typeof requestInvestigationFormSchema>
+
+interface RequestInvestigationDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: {
+ evaluationType: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationMethod?: string,
+ investigationNotes?: string
+ }) => Promise<void>
+ selectedCount: number
+ // 선택된 행에서 가져온 초기값
+ initialData?: {
+ evaluationType?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ qmManagerId?: number,
+ forecastedAt?: Date,
+ investigationAddress?: string,
+ investigationMethod?: string,
+ investigationNotes?: string
+ }
+}
+
+export function RequestInvestigationDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ selectedCount,
+ initialData,
+}: RequestInvestigationDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [qmManagers, setQMManagers] = React.useState<QMUser[]>([])
+ const [isLoadingManagers, setIsLoadingManagers] = React.useState(false)
+
+ // form 객체 생성 시 initialData 활용
+ const form = useForm<RequestInvestigationFormValues>({
+ resolver: zodResolver(requestInvestigationFormSchema),
+ defaultValues: {
+ evaluationType: initialData?.evaluationType || "PURCHASE_SELF_EVAL",
+ qmManagerId: initialData?.qmManagerId || undefined,
+ forecastedAt: initialData?.forecastedAt || undefined,
+ investigationAddress: initialData?.investigationAddress || "",
+ investigationMethod: initialData?.investigationMethod || "",
+ investigationNotes: initialData?.investigationNotes || "",
+ },
+ })
+
+ // Dialog가 열릴 때마다 초기값으로 폼 재설정
+ React.useEffect(() => {
+ if (isOpen) {
+ form.reset({
+ evaluationType: initialData?.evaluationType || "PURCHASE_SELF_EVAL",
+ qmManagerId: initialData?.qmManagerId || undefined,
+ forecastedAt: initialData?.forecastedAt || undefined,
+ investigationAddress: initialData?.investigationAddress || "",
+ investigationMethod: initialData?.investigationMethod || "",
+ investigationNotes: initialData?.investigationNotes || "",
+ });
+ }
+ }, [isOpen, initialData, form]);
+
+ // Dialog가 열릴 때 QM 담당자 목록 로드
+ React.useEffect(() => {
+ if (isOpen && qmManagers.length === 0) {
+ const loadQMManagers = async () => {
+ setIsLoadingManagers(true)
+ try {
+ const result = await getQMManagers()
+ if (result.success && result.data) {
+ setQMManagers(result.data)
+ }
+ } catch (error) {
+ console.error("QM 담당자 로드 오류:", error)
+ } finally {
+ setIsLoadingManagers(false)
+ }
+ }
+
+ loadQMManagers()
+ }
+ }, [isOpen, qmManagers.length])
+
+ async function handleSubmit(data: RequestInvestigationFormValues) {
+ setIsPending(true)
+ try {
+ await onSubmit(data)
+ } finally {
+ setIsPending(false)
+ form.reset()
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>실사 의뢰</DialogTitle>
+ <DialogDescription>
+ {selectedCount}개 협력업체에 대한 실사를 의뢰합니다. 실사 관련 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="evaluationType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 유형</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isPending}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 유형을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
+ <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
+ {/* <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> */}
+ {/* <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> */}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="qmManagerId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 담당자</FormLabel>
+ <FormControl>
+ <UserCombobox
+ users={qmManagers}
+ value={field.value}
+ onChange={field.onChange}
+ placeholder={isLoadingManagers ? "담당자 로딩 중..." : "담당자 선택..."}
+ disabled={isPending || isLoadingManagers}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="forecastedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 예정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ disabled={isPending}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>실사 예정일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) => date < new Date()}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="investigationAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 장소</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사가 진행될 주소를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="investigationMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 방법 (선택사항)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="실사 방법을 입력하세요"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>특이사항 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사 관련 특이사항을 입력하세요"
+ className="resize-none min-h-[60px]"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || isLoadingManagers}>
+ {isPending ? "처리 중..." : "실사 의뢰"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/send-results-dialog.tsx b/lib/pq/pq-review-table-new/send-results-dialog.tsx
index 0a423f7f..3c8614cc 100644
--- a/lib/pq/pq-review-table-new/send-results-dialog.tsx
+++ b/lib/pq/pq-review-table-new/send-results-dialog.tsx
@@ -1,69 +1,212 @@
-"use client"
-
-import * as React from "react"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-
-interface SendResultsDialogProps {
- isOpen: boolean
- onClose: () => void
- onConfirm: () => Promise<void>
- selectedCount: number
-}
-
-export function SendResultsDialog({
- isOpen,
- onClose,
- onConfirm,
- selectedCount,
-}: SendResultsDialogProps) {
- const [isPending, setIsPending] = React.useState(false)
-
- async function handleConfirm() {
- setIsPending(true)
- try {
- await onConfirm()
- } finally {
- setIsPending(false)
- }
- }
-
- return (
- <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>실사 결과 발송</DialogTitle>
- <DialogDescription>
- 선택한 {selectedCount}개 협력업체의 실사 결과를 발송하시겠습니까?
- 완료된 실사만 결과를 발송할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={onClose}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- type="button"
- onClick={handleConfirm}
- disabled={isPending}
- >
- {isPending ? "처리 중..." : "결과 발송"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+
+// 실사 결과 발송을 위한 스키마
+const sendResultsSchema = z.object({
+ purchaseComment: z.string().optional(),
+})
+
+type SendResultsFormValues = z.infer<typeof sendResultsSchema>
+
+interface AuditResult {
+ id: number
+ vendorCode: string
+ vendorName: string
+ vendorEmail: string
+ vendorContactPerson: string
+ pqNumber: string
+ auditItem: string
+ auditFactoryAddress: string
+ auditMethod: string
+ auditResult: string
+ additionalNotes?: string
+ investigationNotes?: string
+}
+
+interface SendResultsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: (data: SendResultsFormValues) => Promise<void>
+ selectedCount: number
+ auditResults: AuditResult[]
+}
+
+export function SendResultsDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ selectedCount,
+ auditResults,
+}: SendResultsDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+
+ const form = useForm<SendResultsFormValues>({
+ resolver: zodResolver(sendResultsSchema),
+ defaultValues: {
+ purchaseComment: "",
+ },
+ })
+
+ async function handleSubmit(data: SendResultsFormValues) {
+ setIsPending(true)
+ try {
+ await onConfirm(data)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const getResultBadgeVariant = (result: string) => {
+ if (result.includes("Pass")) return "default"
+ if (result.includes("Fail")) return "destructive"
+ return "secondary"
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>실사 결과 발송</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedCount}개 협력업체의 실사 결과를 발송하시겠습니까?
+ 완료된 실사만 결과를 발송할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 실사 결과 미리보기 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">실사 결과 미리보기</h3>
+ <div className="space-y-4">
+ {auditResults.map((result) => (
+ <Card key={result.id}>
+ <CardHeader>
+ <CardTitle className="flex items-center justify-between">
+ <span>{result.vendorName} ({result.vendorCode})</span>
+ <Badge variant={getResultBadgeVariant(result.auditResult)}>
+ {result.auditResult}
+ </Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3 text-sm">
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">PQ No.</div>
+ <div className="col-span-2">{result.pqNumber}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">Vendor</div>
+ <div className="col-span-2">{result.vendorCode} | {result.vendorName}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">수신자</div>
+ <div className="col-span-2">{result.vendorContactPerson} ({result.vendorEmail})</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사품목</div>
+ <div className="col-span-2">{result.auditItem}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사공장주소</div>
+ <div className="col-span-2">{result.auditFactoryAddress}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">QM 실사방법</div>
+ <div className="col-span-2">{result.auditMethod}</div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사결과</div>
+ <div className="col-span-2">
+ <Badge variant={getResultBadgeVariant(result.auditResult)}>
+ {result.auditResult}
+ </Badge>
+ </div>
+ </div>
+ {result.investigationNotes && (
+ <div className="grid grid-cols-3 gap-4">
+ <div className="font-medium text-muted-foreground">실사합격조건</div>
+ <div className="col-span-2">{result.investigationNotes}</div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 추가 Comment 입력 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="purchaseComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-base font-medium">
+ 추가 Comment (선택사항)
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="구매 담당자가 협력업체에 추가 설명/Comment 하고자 할 때 활용합니다. 입력하지 않으면 메일 본문에서 생략됩니다."
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isPending}
+ >
+ {isPending ? "처리 중..." : "결과 발송"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
new file mode 100644
index 00000000..63390cb1
--- /dev/null
+++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
@@ -0,0 +1,711 @@
+"use client"
+
+import * as React from "react"
+import { CalendarIcon, X } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { format } from "date-fns"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import { getSiteVisitRequestAction } from "@/lib/site-visit/service"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+
+// 방문실사 요청 폼 스키마
+const siteVisitRequestSchema = z.object({
+ // 실사 기간
+ inspectionDuration: z.number().min(0.5, "실사 기간을 입력해주세요."),
+
+ // 실사 요청일
+ requestedStartDate: z.date({
+ required_error: "실사 시작일을 선택해주세요.",
+ }),
+ requestedEndDate: z.date({
+ required_error: "실사 종료일을 선택해주세요.",
+ }),
+
+ // SHI 실사참석 예정부문
+ shiAttendees: z.object({
+ technicalSales: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ design: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ procurement: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ quality: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ production: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ commissioning: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ other: z.object({
+ checked: z.boolean().default(false),
+ count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0),
+ details: z.string().optional(),
+ }).default({ checked: false, count: 0, details: "" }),
+ }),
+
+ // SHI 참석자 정보 (JSON 형태로 저장) - 기존 필드 유지
+ shiAttendeeDetails: z.string().optional(),
+
+ // 협력업체 요청정보 및 자료
+ vendorRequests: z.object({
+ availableDates: z.boolean().default(false),
+ factoryName: z.boolean().default(false),
+ factoryLocation: z.boolean().default(false),
+ factoryAddress: z.boolean().default(false),
+ factoryPicName: z.boolean().default(false),
+ factoryPicPhone: z.boolean().default(false),
+ factoryPicEmail: z.boolean().default(false),
+ factoryDirections: z.boolean().default(false),
+ accessProcedure: z.boolean().default(false),
+ other: z.boolean().default(false),
+ }),
+
+ // 기타 요청사항
+ otherVendorRequests: z.string().optional(),
+
+ // 추가 요청사항
+ additionalRequests: z.string().optional(),
+})
+
+type SiteVisitRequestFormValues = z.infer<typeof siteVisitRequestSchema>
+
+interface SiteVisitDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: SiteVisitRequestFormValues, attachments?: File[]) => Promise<void>
+ investigation: {
+ id: number
+ evaluationType: "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL"
+ investigationMethod?: string
+ investigationAddress?: string
+ vendorName: string
+ vendorCode: string
+ projectName?: string
+ projectCode?: string
+ pqItems?: string | null
+ }
+}
+
+export function SiteVisitDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ investigation,
+}: SiteVisitDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+
+ const form = useForm<SiteVisitRequestFormValues>({
+ resolver: zodResolver(siteVisitRequestSchema),
+ defaultValues: {
+ inspectionDuration: 1.0,
+ requestedStartDate: undefined,
+ requestedEndDate: undefined,
+ shiAttendees: {
+ technicalSales: { checked: false, count: 0, details: "" },
+ design: { checked: false, count: 0, details: "" },
+ procurement: { checked: false, count: 0, details: "" },
+ quality: { checked: false, count: 0, details: "" },
+ production: { checked: false, count: 0, details: "" },
+ commissioning: { checked: false, count: 0, details: "" },
+ other: { checked: false, count: 0, details: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ },
+ })
+
+ // Dialog가 열릴 때마다 폼 재설정 및 기존 요청 확인
+ React.useEffect(() => {
+ if (isOpen) {
+ // 기존 방문실사 요청이 있는지 확인
+ const checkExistingRequest = async () => {
+ try {
+ const existingRequest = await getSiteVisitRequestAction(investigation.id)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ onClose()
+ return
+ }
+ } catch (error) {
+ console.error("방문실사 요청 상태 확인 중 오류:", error)
+ toast.error("방문실사 요청 상태 확인 중 오류가 발생했습니다.")
+ onClose()
+ return
+ }
+ }
+
+ checkExistingRequest()
+
+ form.reset({
+ inspectionDuration: 1.0,
+ requestedStartDate: undefined,
+ requestedEndDate: undefined,
+ shiAttendees: {
+ technicalSales: { checked: false, count: 0, details: "" },
+ design: { checked: false, count: 0, details: "" },
+ procurement: { checked: false, count: 0, details: "" },
+ quality: { checked: false, count: 0, details: "" },
+ production: { checked: false, count: 0, details: "" },
+ commissioning: { checked: false, count: 0, details: "" },
+ other: { checked: false, count: 0, details: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ })
+ setSelectedFiles([])
+ }
+ }, [isOpen, form, investigation.id, onClose])
+
+ async function handleSubmit(data: SiteVisitRequestFormValues) {
+ setIsPending(true)
+ try {
+ // 제출 전에 한 번 더 기존 요청이 있는지 확인
+ const existingRequest = await getSiteVisitRequestAction(investigation.id)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ onClose()
+ return
+ }
+
+ await onSubmit(data, selectedFiles)
+ toast.success("방문실사 요청이 성공적으로 발송되었습니다.")
+ } catch (error) {
+ toast.error("방문실사 요청 발송 중 오류가 발생했습니다.")
+ console.error("방문실사 요청 오류:", error)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const handleDropAccepted = (files: File[]) => {
+ setSelectedFiles(prev => [...prev, ...files])
+ toast.success(`${files.length}개 파일이 추가되었습니다.`)
+ }
+
+ const handleDropRejected = (files: unknown[]) => {
+ toast.error(`${files.length}개 파일이 거부되었습니다. 파일 크기나 형식을 확인해주세요.`)
+ }
+
+ const removeFile = (index: number) => {
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ const getEvaluationTypeLabel = (type: string) => {
+ switch (type) {
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return type
+ }
+ }
+
+ const getInvestigationMethodLabel = (method: string) => {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>방문실사 요청 생성</DialogTitle>
+ <DialogDescription>
+ 협력업체에 방문실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 대상업체 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel className="text-sm font-medium">대상업체</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <div className="font-medium">{investigation.vendorName}</div>
+ <div className="text-sm text-muted-foreground">({investigation.vendorCode})</div>
+ </div>
+ </div>
+
+ <div>
+ <FormLabel className="text-sm font-medium">대상품목</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <div className="font-medium">{investigation.pqItems || "-"}</div>
+ </div>
+ </div>
+ </div>
+
+
+
+ {/* 실사방법 */}
+ <div>
+ <FormLabel className="text-sm font-medium">실사방법</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <Badge variant="outline">
+ {getEvaluationTypeLabel(investigation.evaluationType)}
+ </Badge>
+ {investigation.investigationMethod && (
+ <div className="mt-2 text-sm text-muted-foreground">
+ {getInvestigationMethodLabel(investigation.investigationMethod)}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 실사기간 */}
+ <FormField
+ control={form.control}
+ name="inspectionDuration"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사기간 (W/D 기준)</FormLabel>
+ <div className="flex items-center gap-2">
+ <FormControl>
+ <Input
+ type="number"
+ step="0.5"
+ min="0.5"
+ placeholder="1.5"
+ {...field}
+ onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-24"
+ />
+ </FormControl>
+ <span className="text-sm text-muted-foreground">일</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사요청일 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="requestedStartDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 시작일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ disabled={isPending}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>시작일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) => date < new Date()}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requestedEndDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 종료일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ disabled={isPending}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>종료일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) => date < new Date()}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* SHI 실사참석 예정부문 */}
+ <div>
+ <FormLabel className="text-sm font-medium">SHI 실사참석 예정부문 ※ 필수값</FormLabel>
+ <div className="text-sm text-muted-foreground mb-4">
+ 삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
+ </div>
+
+ <div className="space-y-4">
+ {[
+ { key: "technicalSales", label: "기술영업" },
+ { key: "design", label: "설계" },
+ { key: "procurement", label: "구매" },
+ { key: "quality", label: "품질" },
+ { key: "production", label: "생산" },
+ { key: "commissioning", label: "시운전" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <div key={item.key} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center space-x-3">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.checked` as `shiAttendees.${typeof item.key}.checked`}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center space-x-2 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-medium">{item.label}</FormLabel>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.count` as `shiAttendees.${typeof item.key}.count`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm">참석 인원</FormLabel>
+ <div className="flex items-center space-x-2">
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-20"
+ />
+ </FormControl>
+ <span className="text-sm text-muted-foreground">명</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.details` as `shiAttendees.${typeof item.key}.details`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm">참석자 정보</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="부서 및 이름 등"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 전체 참석자 상세정보 */}
+ <FormField
+ control={form.control}
+ name="shiAttendeeDetails"
+ render={({ field }) => (
+ <FormItem className="mt-4">
+ <FormLabel>전체 참석자 상세정보 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="전체 참석 예정인력의 상세 정보를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 협력업체 요청정보 및 자료 */}
+ <div>
+ <FormLabel className="text-sm font-medium">협력업체 요청정보 및 자료</FormLabel>
+ <div className="text-sm text-muted-foreground mb-2">
+ 협력업체에게 요청할 정보를 선택하세요. 선택된 항목들은 협력업체 정보 입력 폼에 포함됩니다.
+ </div>
+ <div className="mt-2 space-y-2">
+ {[
+ { key: "factoryName", label: "공장명" },
+ { key: "factoryLocation", label: "공장위치" },
+ { key: "factoryAddress", label: "공장주소" },
+ { key: "factoryPicName", label: "공장 PIC 이름" },
+ { key: "factoryPicPhone", label: "공장 PIC 전화번호" },
+ { key: "factoryPicEmail", label: "공장 PIC 이메일" },
+ { key: "factoryDirections", label: "공장 가는 방법" },
+ { key: "accessProcedure", label: "공장 출입절차" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <FormField
+ key={item.key}
+ control={form.control}
+ name={`vendorRequests.${item.key}` as `vendorRequests.${typeof item.key}`}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={!!field.value}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-normal">{item.label}</FormLabel>
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+ <FormField
+ control={form.control}
+ name="otherVendorRequests"
+ render={({ field }) => (
+ <FormItem className="mt-4">
+ <FormLabel>기타 요청사항</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="기타 요청사항을 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 추가 요청사항 */}
+ <FormField
+ control={form.control}
+ name="additionalRequests"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>추가 요청사항 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 요청사항을 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 첨부파일 */}
+ <div>
+ <FormLabel className="text-sm font-medium">첨부파일 (선택사항)</FormLabel>
+ <div className="mt-2">
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {() => (
+ <FormItem>
+ <DropzoneZone className="flex justify-center h-24">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: 600MB
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>
+ 또는 클릭하여 파일을 선택하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ </Dropzone>
+ {selectedFiles.length > 0 && (
+ <div className="mt-2 space-y-1">
+ {selectedFiles.map((file, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
+ <span className="text-sm">{file.name}</span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ disabled={isPending}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? "처리 중..." : "방문실사 요청 생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/user-combobox.tsx b/lib/pq/pq-review-table-new/user-combobox.tsx
index 0fb0e4c8..560f675a 100644
--- a/lib/pq/pq-review-table-new/user-combobox.tsx
+++ b/lib/pq/pq-review-table-new/user-combobox.tsx
@@ -1,122 +1,122 @@
-"use client"
-
-import * as React from "react"
-import { Check, ChevronsUpDown } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-
-interface User {
- id: number
- name: string
- email: string
- department?: string
-}
-
-interface UserComboboxProps {
- users: User[]
- value: number | null
- onChange: (value: number) => void
- placeholder?: string
- disabled?: boolean
-}
-
-export function UserCombobox({
- users,
- value,
- onChange,
- placeholder = "담당자 선택...",
- disabled = false
-}: UserComboboxProps) {
- const [open, setOpen] = React.useState(false)
- const [inputValue, setInputValue] = React.useState("")
-
- const selectedUser = React.useMemo(() => {
- return users.find(user => user.id === value)
- }, [users, value])
-
- return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={open}
- className={cn(
- "w-full justify-between",
- !value && "text-muted-foreground"
- )}
- disabled={disabled}
- >
- {selectedUser ? (
- <span className="flex items-center">
- <span className="font-medium">{selectedUser.name}</span>
- {selectedUser.department && (
- <span className="ml-2 text-xs text-muted-foreground">
- ({selectedUser.department})
- </span>
- )}
- </span>
- ) : (
- placeholder
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[300px] p-0">
- <Command>
- <CommandInput
- placeholder="담당자 검색..."
- value={inputValue}
- onValueChange={setInputValue}
- />
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup className="max-h-[200px] overflow-y-auto">
- {users.map((user) => (
- <CommandItem
- key={user.id}
- value={user.email} // 이메일을 value로 사용
- onSelect={() => {
- onChange(user.id)
- setOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- value === user.id ? "opacity-100" : "opacity-0"
- )}
- />
- <div className="flex flex-col truncate">
- <div className="flex items-center">
- <span className="font-medium">{user.name}</span>
- {user.department && (
- <span className="ml-2 text-xs text-muted-foreground">
- ({user.department})
- </span>
- )}
- </div>
- <span className="text-xs text-muted-foreground truncate">
- {user.email}
- </span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </Command>
- </PopoverContent>
- </Popover>
- )
+"use client"
+
+import * as React from "react"
+import { Check, ChevronsUpDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+interface User {
+ id: number
+ name: string
+ email: string
+ department?: string
+}
+
+interface UserComboboxProps {
+ users: User[]
+ value: number | null
+ onChange: (value: number) => void
+ placeholder?: string
+ disabled?: boolean
+}
+
+export function UserCombobox({
+ users,
+ value,
+ onChange,
+ placeholder = "담당자 선택...",
+ disabled = false
+}: UserComboboxProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState("")
+
+ const selectedUser = React.useMemo(() => {
+ return users.find(user => user.id === value)
+ }, [users, value])
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className={cn(
+ "w-full justify-between",
+ !value && "text-muted-foreground"
+ )}
+ disabled={disabled}
+ >
+ {selectedUser ? (
+ <span className="flex items-center">
+ <span className="font-medium">{selectedUser.name}</span>
+ {selectedUser.department && (
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({selectedUser.department})
+ </span>
+ )}
+ </span>
+ ) : (
+ placeholder
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="담당자 검색..."
+ value={inputValue}
+ onValueChange={setInputValue}
+ />
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup className="max-h-[200px] overflow-y-auto">
+ {users.map((user) => (
+ <CommandItem
+ key={user.id}
+ value={user.email} // 이메일을 value로 사용
+ onSelect={() => {
+ onChange(user.id)
+ setOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ value === user.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col truncate">
+ <div className="flex items-center">
+ <span className="font-medium">{user.name}</span>
+ {user.department && (
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({user.department})
+ </span>
+ )}
+ </div>
+ <span className="text-xs text-muted-foreground truncate">
+ {user.email}
+ </span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
index 6bfa8c7f..d99f201e 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -1,640 +1,787 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Eye, PaperclipIcon, FileEdit } from "lucide-react"
-
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
-import { useRouter } from "next/navigation"
-
-// PQ 제출 타입 정의
-export interface PQSubmission {
- id: number
- pqNumber: string
- type: string
- status: string
- requesterName: string | null // 요청자 이름
- createdAt: Date
- updatedAt: Date
- submittedAt: Date | null
- approvedAt: Date | null
- rejectedAt: Date | null
- rejectReason: string | null
- vendorId: number
- vendorName: string
- vendorCode: string
- taxId: string
- vendorStatus: string
- projectId: number | null
- projectName: string | null
- projectCode: string | null
- answerCount: number
- attachmentCount: number
- pqStatus: string
- pqTypeLabel: string
- investigation: {
- id: number
- investigationStatus: string
- requesterName: string | null // 실사 요청자 이름
- evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT" | null
- qmManagerId: number | null
- qmManagerName: string | null // QM 담당자 이름
- qmManagerEmail: string | null // QM 담당자 이메일
- investigationAddress: string | null
- investigationMethod: string | null
- scheduledStartAt: Date | null
- scheduledEndAt: Date | null
- requestedAt: Date | null
- confirmedAt: Date | null
- completedAt: Date | null
- forecastedAt: Date | null
- evaluationScore: number | null
- evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | null
- investigationNotes: string | null
- } | null
- // 통합 상태를 위한 새 필드
- combinedStatus: {
- status: string
- label: string
- variant: "default" | "outline" | "secondary" | "destructive" | "success"
- }
-}
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQSubmission> | null>>;
- router: NextRouter;
-}
-
-// 상태에 따른 Badge 변형 결정 함수
-function getStatusBadge(status: string) {
- switch (status) {
- case "REQUESTED":
- return <Badge variant="outline">요청됨</Badge>
- case "IN_PROGRESS":
- return <Badge variant="secondary">진행 중</Badge>
- case "SUBMITTED":
- return <Badge>제출됨</Badge>
- case "APPROVED":
- return <Badge variant="success">승인됨</Badge>
- case "REJECTED":
- return <Badge variant="destructive">거부됨</Badge>
- default:
- return <Badge variant="outline">{status}</Badge>
- }
-}
-
-/**
- * tanstack table 컬럼 정의
- */
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<PQSubmission>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<PQSubmission> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) 일반 컬럼들
- // --------------------------
- // --------------------------------------
-
- const pqNoColumn: ColumnDef<PQSubmission> = {
- accessorKey: "pqNumber",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="PQ No." />
- ),
- cell: ({ row }) => (
- <div className="flex flex-col">
- <span className="font-medium">{row.getValue("pqNumber")}</span>
- </div>
- ),
- }
-
- // 협력업체 컬럼
- const vendorColumn: ColumnDef<PQSubmission> = {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="협력업체" />
- ),
- cell: ({ row }) => (
- <div className="flex flex-col">
- <span className="font-medium">{row.getValue("vendorName")}</span>
- <span className="text-xs text-muted-foreground">{row.original.vendorCode ? row.original.vendorCode : "-"}/{row.original.taxId}</span>
- </div>
- ),
- }
-
- // PQ 유형 컬럼
- const typeColumn: ColumnDef<PQSubmission> = {
- accessorKey: "type",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="PQ 유형" />
- ),
- cell: ({ row }) => {
- return (
- <div className="flex items-center">
- <Badge variant={row.original.type === "PROJECT" ? "default" : "outline"}>
- {row.original.pqTypeLabel}
- </Badge>
- </div>
- )
- },
- filterFn: (row, id, value) => {
- return value.includes(row.getValue(id))
- },
- }
-
- // 프로젝트 컬럼
- const projectColumn: ColumnDef<PQSubmission> = {
- accessorKey: "projectName",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="프로젝트" />
- ),
- cell: ({ row }) => {
- const projectName = row.original.projectName
- const projectCode = row.original.projectCode
-
- if (!projectName) {
- return <span className="text-muted-foreground">-</span>
- }
-
- return (
- <div className="flex flex-col">
- <span>{projectName}</span>
- {projectCode && (
- <span className="text-xs text-muted-foreground">{projectCode}</span>
- )}
- </div>
- )
- },
- }
-
- // 상태 컬럼
- const statusColumn: ColumnDef<PQSubmission> = {
- accessorKey: "combinedStatus",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="진행현황" />
- ),
- cell: ({ row }) => {
- const combinedStatus = getCombinedStatus(row.original);
- return <Badge variant={combinedStatus.variant}>{combinedStatus.label}</Badge>;
- },
- filterFn: (row, id, value) => {
- const combinedStatus = getCombinedStatus(row.original);
- return value.includes(combinedStatus.status);
- },
- };
-
- // PQ 상태와 실사 상태를 결합하는 헬퍼 함수
- function getCombinedStatus(submission: PQSubmission) {
- // PQ가 승인되지 않은 경우, PQ 상태를 우선 표시
- if (submission.status !== "APPROVED") {
- switch (submission.status) {
- case "REQUESTED":
- return { status: "PQ_REQUESTED", label: "PQ 요청됨", variant: "outline" as const };
- case "IN_PROGRESS":
- return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const };
- case "SUBMITTED":
- return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const };
- case "REJECTED":
- return { status: "PQ_REJECTED", label: "PQ 거부됨", variant: "destructive" as const };
- default:
- return { status: submission.status, label: submission.status, variant: "outline" as const };
- }
- }
-
- // PQ가 승인되었지만 실사가 없는 경우
- if (!submission.investigation) {
- return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
- }
-
- // PQ가 승인되고 실사가 있는 경우
- switch (submission.investigation.investigationStatus) {
- case "PLANNED":
- return { status: "INVESTIGATION_PLANNED", label: "실사 계획됨", variant: "outline" as const };
- case "IN_PROGRESS":
- return { status: "INVESTIGATION_IN_PROGRESS", label: "실사 진행 중", variant: "secondary" as const };
- case "COMPLETED":
- // 실사 완료 후 평가 결과에 따라 다른 상태 표시
- if (submission.investigation.evaluationResult) {
- switch (submission.investigation.evaluationResult) {
- case "APPROVED":
- return { status: "INVESTIGATION_APPROVED", label: "실사 승인", variant: "success" as const };
- case "SUPPLEMENT":
- return { status: "INVESTIGATION_SUPPLEMENT", label: "실사 보완필요", variant: "secondary" as const };
- case "REJECTED":
- return { status: "INVESTIGATION_REJECTED", label: "실사 불가", variant: "destructive" as const };
- default:
- return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
- }
- }
- return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
- case "CANCELED":
- return { status: "INVESTIGATION_CANCELED", label: "실사 취소됨", variant: "destructive" as const };
- default:
- return {
- status: `INVESTIGATION_${submission.investigation.investigationStatus}`,
- label: `실사 ${submission.investigation.investigationStatus}`,
- variant: "outline" as const
- };
- }
- }
-
- const evaluationTypeColumn: ColumnDef<PQSubmission> = {
- accessorKey: "evaluationType",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="평가 유형" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.evaluationType) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- switch (investigation.evaluationType) {
- case "SITE_AUDIT":
- return <Badge variant="outline">실사의뢰평가</Badge>;
- case "QM_SELF_AUDIT":
- return <Badge variant="secondary">QM자체평가</Badge>;
- default:
- return <span>{investigation.evaluationType}</span>;
- }
- },
- filterFn: (row, id, value) => {
- const investigation = row.original.investigation;
- if (!investigation || !investigation.evaluationType) return value.includes("null");
- return value.includes(investigation.evaluationType);
- },
- };
-
-
- const evaluationResultColumn: ColumnDef<PQSubmission> = {
- accessorKey: "evaluationResult",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="평가 결과" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.evaluationResult) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- switch (investigation.evaluationResult) {
- case "APPROVED":
- return <Badge variant="success">승인</Badge>;
- case "SUPPLEMENT":
- return <Badge variant="secondary">보완</Badge>;
- case "REJECTED":
- return <Badge variant="destructive">불가</Badge>;
- default:
- return <span>{investigation.evaluationResult}</span>;
- }
- },
- filterFn: (row, id, value) => {
- const investigation = row.original.investigation;
- if (!investigation || !investigation.evaluationResult) return value.includes("null");
- return value.includes(investigation.evaluationResult);
- },
- };
-
- // 답변 수 컬럼
- const answerCountColumn: ColumnDef<PQSubmission> = {
- accessorKey: "answerCount",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="답변 수" />
- ),
- cell: ({ row }) => {
- return (
- <div className="flex items-center gap-2">
- <span>{row.original.answerCount}</span>
- </div>
- )
- },
- }
-
- const investigationAddressColumn: ColumnDef<PQSubmission> = {
- accessorKey: "investigationAddress",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="실사 주소" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.evaluationType) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex items-center gap-2">
- <span>{investigation.investigationAddress}</span>
- </div>
- )
- },
- }
-
- const investigationNotesColumn: ColumnDef<PQSubmission> = {
- accessorKey: "investigationNotes",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="QM 의견" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.investigationNotes) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex items-center gap-2">
- <span>{investigation.investigationNotes}</span>
- </div>
- )
- },
- }
-
-
- const investigationRequestedAtColumn: ColumnDef<PQSubmission> = {
- accessorKey: "investigationRequestedAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="실사 의뢰일" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.requestedAt) {
- return <span className="text-muted-foreground">-</span>;
- }
- const dateVal = investigation.requestedAt
-
- return (
- <div className="flex items-center gap-2">
- <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
- </div>
- )
- },
- }
-
-
- const investigationForecastedAtColumn: ColumnDef<PQSubmission> = {
- accessorKey: "investigationForecastedAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="실사 예정일" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.forecastedAt) {
- return <span className="text-muted-foreground">-</span>;
- }
- const dateVal = investigation.forecastedAt
-
- return (
- <div className="flex items-center gap-2">
- <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
- </div>
- )
- },
- }
-
- const investigationConfirmedAtColumn: ColumnDef<PQSubmission> = {
- accessorKey: "investigationConfirmedAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="실사 확정일" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.confirmedAt) {
- return <span className="text-muted-foreground">-</span>;
- }
- const dateVal = investigation.confirmedAt
-
- return (
- <div className="flex items-center gap-2">
- <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
- </div>
- )
- },
- }
-
- const investigationCompletedAtColumn: ColumnDef<PQSubmission> = {
- accessorKey: "investigationCompletedAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="실제 실사일" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.completedAt) {
- return <span className="text-muted-foreground">-</span>;
- }
- const dateVal = investigation.completedAt
-
- return (
- <div className="flex items-center gap-2">
- <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
- </div>
- )
- },
- }
-
- // 제출일 컬럼
- const createdAtColumn: ColumnDef<PQSubmission> = {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="PQ 전송일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.original.createdAt as Date
- return formatDate(dateVal, 'KR')
- },
- }
-
- // 제출일 컬럼
- const submittedAtColumn: ColumnDef<PQSubmission> = {
- accessorKey: "submittedAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="PQ 회신일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.original.submittedAt as Date
- return dateVal ? formatDate(dateVal, 'KR') : "-"
- },
- }
-
- // 승인/거부일 컬럼
- const approvalDateColumn: ColumnDef<PQSubmission> = {
- accessorKey: "approvedAt",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="PQ 승인/거부일" />
- ),
- cell: ({ row }) => {
- if (row.original.approvedAt) {
- return <span className="text-green-600">{formatDate(row.original.approvedAt, "KR")}</span>
- }
- if (row.original.rejectedAt) {
- return <span className="text-red-600">{formatDate(row.original.rejectedAt, "KR")}</span>
- }
- return "-"
- },
- }
-
- // ----------------------------------------------------------------
- // 3) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<PQSubmission> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const pq = row.original
- const isSubmitted = pq.status === "SUBMITTED"
- const reviewUrl = `/evcp/pq_new/${pq.vendorId}/${pq.id}`
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => {
- router.push(reviewUrl);
- }}
- >
- {isSubmitted ? (
- <>
- <FileEdit className="mr-2 h-4 w-4" />
- 검토
- </>
- ) : (
- <>
- <Eye className="mr-2 h-4 w-4" />
- 보기
- </>
- )}
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // 요청자 컬럼 추가
-const requesterColumn: ColumnDef<PQSubmission> = {
- accessorKey: "requesterName",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="PQ/실사 요청자" />
- ),
- cell: ({ row }) => {
- // PQ 요청자와 실사 요청자를 모두 표시
- const pqRequesterName = row.original.requesterName;
- const investigationRequesterName = row.original.investigation?.requesterName;
-
- // 상태에 따라 적절한 요청자 표시
- const status = getCombinedStatus(row.original).status;
-
- if (status.startsWith('INVESTIGATION_') && investigationRequesterName) {
- return <span>{investigationRequesterName}</span>;
- }
-
- return pqRequesterName
- ? <span>{pqRequesterName}</span>
- : <span className="text-muted-foreground">-</span>;
- },
-};
-const qmManagerColumn: ColumnDef<PQSubmission> = {
- accessorKey: "qmManager",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="QM 담당자" />
- ),
- cell: ({ row }) => {
- const investigation = row.original.investigation;
-
- if (!investigation || !investigation.qmManagerName) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span>{investigation.qmManagerName}</span>
- {investigation.qmManagerEmail && (
- <span className="text-xs text-muted-foreground">{investigation.qmManagerEmail}</span>
- )}
- </div>
- );
- },
-};
-
-
- // ----------------------------------------------------------------
- // 4) 최종 컬럼 배열
- // ----------------------------------------------------------------
- return [
- selectColumn,
- statusColumn, // 통합된 진행현황 컬럼
- pqNoColumn,
- vendorColumn,
- investigationAddressColumn,
- typeColumn,
- projectColumn,
- createdAtColumn,
- submittedAtColumn,
- approvalDateColumn,
- answerCountColumn,
- evaluationTypeColumn, // 평가 유형 컬럼
- investigationForecastedAtColumn,
- investigationRequestedAtColumn,
- investigationConfirmedAtColumn,
- investigationCompletedAtColumn,
- evaluationResultColumn, // 평가 결과 컬럼
- requesterColumn,
- qmManagerColumn,
- investigationNotesColumn,
- actionsColumn,
- ];
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Eye, FileEdit, Trash2, Building2, FileText, Edit } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { useRouter } from "next/navigation"
+import { PQDeleteDialog } from "@/components/pq-input/pq-delete-dialog"
+
+// PQ 제출 타입 정의
+export interface PQSubmission {
+ // PQ 제출 정보
+ id: number
+ pqNumber: string
+ type: string
+ status: string
+ requesterName: string | null // 요청자 이름
+ createdAt: Date
+ updatedAt: Date
+ submittedAt: Date | null
+ approvedAt: Date | null
+ rejectedAt: Date | null
+ rejectReason: string | null
+
+ // 협력업체 정보
+ vendorId: number
+ vendorName: string
+ vendorCode: string
+ taxId: string
+ vendorStatus: string
+ email: string
+ // 프로젝트 정보
+ projectId: number | null
+ projectName: string | null
+ projectCode: string | null
+
+ // 답변 정보
+ answerCount: number
+ attachmentCount: number
+
+ // PQ 상태
+ pqStatus: string
+ pqTypeLabel: string
+
+ // PQ 대상품목
+ pqItems: string | null
+
+ // 방문실사 요청 정보
+ siteVisitRequestId: number | null // 방문실사 요청 ID
+
+ // 실사 정보
+ investigation: {
+ id: number
+ investigationStatus: string
+ requesterName: string | null // 실사 요청자 이름
+ evaluationType: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL" | null
+ qmManagerId: number | null
+ qmManagerName: string | null // QM 담당자 이름
+ qmManagerEmail: string | null // QM 담당자 이메일
+ investigationAddress: string | null
+ investigationMethod: string | null
+ scheduledStartAt: Date | null
+ scheduledEndAt: Date | null
+ requestedAt: Date | null
+ confirmedAt: Date | null
+ completedAt: Date | null
+ forecastedAt: Date | null
+ evaluationScore: number | null
+ evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | "RESULT_SENT" | null
+ investigationNotes: string | null
+ } | null
+ // 통합 상태를 위한 새 필드
+ combinedStatus: {
+ status: string
+ label: string
+ variant: "default" | "outline" | "secondary" | "destructive" | "success"
+ }
+}
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQSubmission> | null>>;
+ router: NextRouter;
+}
+
+// 상태에 따른 Badge 변형 결정 함수
+function getStatusBadge(status: string) {
+ switch (status) {
+ case "REQUESTED":
+ return <Badge variant="outline">요청됨</Badge>
+ case "IN_PROGRESS":
+ return <Badge variant="secondary">진행 중</Badge>
+ case "SUBMITTED":
+ return <Badge>제출됨</Badge>
+ case "APPROVED":
+ return <Badge variant="success">승인됨</Badge>
+ case "REJECTED":
+ return <Badge variant="destructive">거부됨</Badge>
+ default:
+ return <Badge variant="outline">{status}</Badge>
+ }
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<PQSubmission>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<PQSubmission> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 일반 컬럼들
+ // --------------------------
+ // --------------------------------------
+
+ const pqNoColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "pqNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ No." />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.getValue("pqNumber")}</span>
+ </div>
+ ),
+ }
+
+ // 협력업체 컬럼
+ const vendorColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.getValue("vendorName")}</span>
+ <span className="text-xs text-muted-foreground">{row.original.vendorCode ? row.original.vendorCode : "-"}/{row.original.taxId}</span>
+ </div>
+ ),
+ }
+
+ // PQ 유형 컬럼
+ const typeColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "type",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 유형" />
+ ),
+ cell: ({ row }) => {
+ const { type, pqTypeLabel } = row.original;
+ let label = pqTypeLabel;
+ if (type === "NON_INSPECTION") {
+ label = "미실사 PQ";
+ }
+ return (
+ <div className="flex items-center">
+ <Badge variant={type === "PROJECT" ? "default" : "outline"}>
+ {label}
+ </Badge>
+ </div>
+ );
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ }
+
+ // 프로젝트 컬럼
+ const projectColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "projectName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => {
+ const projectName = row.original.projectName
+ const projectCode = row.original.projectCode
+
+ if (!projectName) {
+ return <span className="text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="flex flex-col">
+ <span>{projectName}</span>
+ {projectCode && (
+ <span className="text-xs text-muted-foreground">{projectCode}</span>
+ )}
+ </div>
+ )
+ },
+ }
+
+ // 상태 컬럼
+ const statusColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "combinedStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="진행현황" />
+ ),
+ cell: ({ row }) => {
+ const combinedStatus = getCombinedStatus(row.original);
+ return <Badge variant={combinedStatus.variant}>{combinedStatus.label}</Badge>;
+ },
+ filterFn: (row, id, value) => {
+ const combinedStatus = getCombinedStatus(row.original);
+ return value.includes(combinedStatus.status);
+ },
+ };
+
+ // PQ 상태와 실사 상태를 결합하는 헬퍼 함수
+ function getCombinedStatus(submission: PQSubmission) {
+ // PQ가 승인되지 않은 경우, PQ 상태를 우선 표시
+ if (submission.status !== "APPROVED") {
+ switch (submission.status) {
+ case "REQUESTED":
+ return { status: "PQ_REQUESTED", label: "PQ 요청됨", variant: "outline" as const };
+ case "IN_PROGRESS":
+ return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const };
+ case "SUBMITTED":
+ return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const };
+ case "REJECTED":
+ return { status: "PQ_REJECTED", label: "PQ 거부됨", variant: "destructive" as const };
+ default:
+ return { status: submission.status, label: submission.status, variant: "outline" as const };
+ }
+ }
+
+ // PQ가 승인되었지만 실사가 없는 경우
+ if (!submission.investigation) {
+ return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
+ }
+
+ // PQ가 승인되고 실사가 있는 경우
+ switch (submission.investigation.investigationStatus) {
+ case "PLANNED":
+ return { status: "INVESTIGATION_PLANNED", label: "실사 계획됨", variant: "outline" as const };
+ case "IN_PROGRESS":
+ return { status: "INVESTIGATION_IN_PROGRESS", label: "실사 진행 중", variant: "secondary" as const };
+ case "COMPLETED":
+ // 실사 완료 후 평가 결과에 따라 다른 상태 표시
+ if (submission.investigation.evaluationResult) {
+ switch (submission.investigation.evaluationResult) {
+ case "APPROVED":
+ return { status: "INVESTIGATION_APPROVED", label: "실사 승인", variant: "success" as const };
+ case "SUPPLEMENT":
+ return { status: "INVESTIGATION_SUPPLEMENT", label: "실사 보완필요", variant: "secondary" as const };
+ case "REJECTED":
+ return { status: "INVESTIGATION_REJECTED", label: "실사 불가", variant: "destructive" as const };
+ default:
+ return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
+ }
+ }
+ return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
+ case "CANCELED":
+ return { status: "INVESTIGATION_CANCELED", label: "실사 취소됨", variant: "destructive" as const };
+ case "RESULT_SENT":
+ return { status: "INVESTIGATION_RESULT_SENT", label: "실사 결과 발송", variant: "success" as const };
+ default:
+ return {
+ status: `INVESTIGATION_${submission.investigation.investigationStatus}`,
+ label: `실사 ${submission.investigation.investigationStatus}`,
+ variant: "outline" as const
+ };
+ }
+ }
+
+ const evaluationTypeColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "evaluationType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가 유형" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.evaluationType) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ switch (investigation.evaluationType) {
+ case "PURCHASE_SELF_EVAL":
+ return <Badge variant="outline">구매자체평가</Badge>;
+ case "DOCUMENT_EVAL":
+ return <Badge variant="secondary">서류평가</Badge>;
+ case "PRODUCT_INSPECTION":
+ return <Badge variant="default">제품검사평가</Badge>;
+ case "SITE_VISIT_EVAL":
+ return <Badge variant="destructive">방문실사평가</Badge>;
+ default:
+ return <span>{investigation.evaluationType}</span>;
+ }
+ },
+ filterFn: (row, id, value) => {
+ const investigation = row.original.investigation;
+ if (!investigation || !investigation.evaluationType) return value.includes("null");
+ return value.includes(investigation.evaluationType);
+ },
+ };
+
+
+ const evaluationResultColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "evaluationResult",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가 결과" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.evaluationResult) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ switch (investigation.evaluationResult) {
+ case "APPROVED":
+ return <Badge variant="success">승인</Badge>;
+ case "SUPPLEMENT":
+ return <Badge variant="secondary">보완</Badge>;
+ case "REJECTED":
+ return <Badge variant="destructive">불가</Badge>;
+ default:
+ return <span>{investigation.evaluationResult}</span>;
+ }
+ },
+ filterFn: (row, id, value) => {
+ const investigation = row.original.investigation;
+ if (!investigation || !investigation.evaluationResult) return value.includes("null");
+ return value.includes(investigation.evaluationResult);
+ },
+ };
+
+ // 답변 수 컬럼
+ const answerCountColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "answerCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="답변 수" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex items-center gap-2">
+ <span>{row.original.answerCount}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationAddressColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationAddress",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실사 주소" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.evaluationType) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{investigation.investigationAddress}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationNotesColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationNotes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="QM 의견" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.investigationNotes) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{investigation.investigationNotes}</span>
+ </div>
+ )
+ },
+ }
+ const investigationMethodColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationMethod",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="QM실사방법" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+ if (!investigation || !investigation.investigationMethod) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ switch (investigation.investigationMethod) {
+ case "PURCHASE_SELF_EVAL":
+ return <Badge variant="outline">구매자체평가</Badge>;
+ case "DOCUMENT_EVAL":
+ return <Badge variant="secondary">서류평가</Badge>;
+ case "PRODUCT_INSPECTION":
+ return <Badge variant="default">제품검사평가</Badge>;
+ case "SITE_VISIT_EVAL":
+ return <Badge variant="destructive">방문실사평가</Badge>;
+ default:
+ return <span>{investigation.investigationMethod}</span>;
+ }
+ },
+ }
+
+ // 실사품목 컬럼
+ const pqItemsColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "pqItems",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실사품목" />
+ ),
+ cell: ({ row }) => {
+ const pqItems = row.original.pqItems;
+
+ if (!pqItems) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <span className="text-sm">{pqItems}</span>
+ </div>
+ )
+ },
+ }
+
+
+ const investigationRequestedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationRequestedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실사 의뢰일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.requestedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.requestedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+
+ const investigationForecastedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationForecastedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실사 예정일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.forecastedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.forecastedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationConfirmedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationConfirmedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실사 확정일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.confirmedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.confirmedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+ const investigationCompletedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "investigationCompletedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실제 실사일" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.completedAt) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ const dateVal = investigation.completedAt
+
+ return (
+ <div className="flex items-center gap-2">
+ <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span>
+ </div>
+ )
+ },
+ }
+
+ // 제출일 컬럼
+ const createdAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 전송일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.original.createdAt as Date
+ return formatDate(dateVal, 'KR')
+ },
+ }
+
+ // 제출일 컬럼
+ const submittedAtColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 회신일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.original.submittedAt as Date
+ return dateVal ? formatDate(dateVal, 'KR') : "-"
+ },
+ }
+
+ // 승인/거부일 컬럼
+ const approvalDateColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "approvedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 승인/거부일" />
+ ),
+ cell: ({ row }) => {
+ if (row.original.approvedAt) {
+ return <span className="text-green-600">{formatDate(row.original.approvedAt)}</span>
+ }
+ if (row.original.rejectedAt) {
+ return <span className="text-red-600">{formatDate(row.original.rejectedAt)}</span>
+ }
+ return "-"
+ },
+ }
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<PQSubmission> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const pq = row.original
+ const isSubmitted = pq.status === "SUBMITTED"
+ const reviewUrl = `/evcp/pq_new/${pq.vendorId}/${pq.id}`
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => {
+ router.push(reviewUrl);
+ }}
+ >
+ {isSubmitted ? (
+ <>
+ <FileEdit className="mr-2 h-4 w-4" />
+ 검토
+ </>
+ ) : (
+ <>
+ <Eye className="mr-2 h-4 w-4" />
+ 보기
+ </>
+ )}
+ </DropdownMenuItem>
+
+ {/* 방문실사 버튼 - 제품검사평가 또는 방문실사평가인 경우에만 표시 */}
+ {pq.investigation &&
+ (pq.investigation.investigationMethod === "PRODUCT_INSPECTION" ||
+ pq.investigation.investigationMethod === "SITE_VISIT_EVAL") && (
+ <>
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ // 방문실사 다이얼로그 열기 로직
+ setRowAction({
+ type: "site-visit",
+ row: row.original
+ });
+ }}
+ >
+ <Building2 className="mr-2 h-4 w-4" />
+ 방문실사
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ // 협력업체 정보 조회 다이얼로그 열기 로직
+ setRowAction({
+ type: "vendor-info-view",
+ row: row.original
+ });
+ }}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 협력업체 정보 조회
+ </DropdownMenuItem>
+ </>
+ )}
+
+ {/* 실사 정보 수정 버튼 - 구매자체평가인 경우에만 표시 */}
+ {pq.investigation &&
+ pq.investigation.investigationMethod === "PURCHASE_SELF_EVAL" && (
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ // 실사 정보 수정 다이얼로그 열기 로직
+ setRowAction({
+ type: "edit-investigation",
+ row: row.original
+ });
+ }}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 실사 정보 수정
+ </DropdownMenuItem>
+ )}
+
+ {/* 삭제 메뉴 - REQUESTED 상태일 때만 표시 */}
+ {pq.status === "REQUESTED" && (
+ <PQDeleteDialog
+ pqSubmissionId={pq.id}
+ status={pq.status}
+ >
+ <DropdownMenuItem
+ onSelect={(e) => {
+ e.preventDefault();
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </PQDeleteDialog>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // 요청자 컬럼 추가
+const requesterColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "requesterName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ/실사 요청자" />
+ ),
+ cell: ({ row }) => {
+ // PQ 요청자와 실사 요청자를 모두 표시
+ const pqRequesterName = row.original.requesterName;
+ const investigationRequesterName = row.original.investigation?.requesterName;
+
+ // 상태에 따라 적절한 요청자 표시
+ const status = getCombinedStatus(row.original).status;
+
+ if (status.startsWith('INVESTIGATION_') && investigationRequesterName) {
+ return <span>{investigationRequesterName}</span>;
+ }
+
+ return pqRequesterName
+ ? <span>{pqRequesterName}</span>
+ : <span className="text-muted-foreground">-</span>;
+ },
+};
+const qmManagerColumn: ColumnDef<PQSubmission> = {
+ accessorKey: "qmManager",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="QM 담당자" />
+ ),
+ cell: ({ row }) => {
+ const investigation = row.original.investigation;
+
+ if (!investigation || !investigation.qmManagerName) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ return (
+ <div className="flex flex-col">
+ <span>{investigation.qmManagerName}</span>
+ {investigation.qmManagerEmail && (
+ <span className="text-xs text-muted-foreground">{investigation.qmManagerEmail}</span>
+ )}
+ </div>
+ );
+ },
+};
+
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ statusColumn, // 통합된 진행현황 컬럼
+ pqNoColumn,
+ vendorColumn,
+ investigationAddressColumn,
+ typeColumn,
+ projectColumn,
+ pqItemsColumn, // 실사품목 컬럼
+ createdAtColumn,
+ submittedAtColumn,
+ approvalDateColumn,
+ answerCountColumn,
+ evaluationTypeColumn, // 평가 유형 컬럼
+ investigationMethodColumn,
+ investigationForecastedAtColumn,
+ investigationRequestedAtColumn,
+ investigationConfirmedAtColumn,
+ investigationCompletedAtColumn,
+ evaluationResultColumn, // 평가 결과 컬럼
+ requesterColumn,
+ qmManagerColumn,
+ investigationNotesColumn,
+ actionsColumn,
+ ];
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index abba72d1..48aeb552 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -1,351 +1,407 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, ClipboardCheck, X, Send } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { PQSubmission } from "./vendors-table-columns"
-import {
- requestInvestigationAction,
- cancelInvestigationAction,
- sendInvestigationResultsAction,
- getFactoryLocationAnswer
-} from "@/lib/pq/service"
-import { RequestInvestigationDialog } from "./request-investigation-dialog"
-import { CancelInvestigationDialog } from "./cancel-investigation-dialog"
-import { SendResultsDialog } from "./send-results-dialog"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<PQSubmission>
-}
-
-interface InvestigationInitialData {
- evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT";
- qmManagerId?: number;
- forecastedAt?: Date;
- createdAt?: Date;
- investigationAddress?: string;
- investigationNotes?: string;
-}
-
-export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const [isLoading, setIsLoading] = React.useState(false)
-
- // Dialog 상태 관리
- const [isRequestDialogOpen, setIsRequestDialogOpen] = React.useState(false)
- const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false)
- const [isSendResultsDialogOpen, setIsSendResultsDialogOpen] = React.useState(false)
-
- // 초기 데이터 상태
- const [dialogInitialData, setDialogInitialData] = React.useState<InvestigationInitialData | undefined>(undefined)
-
- // 실사 의뢰 대화상자 열기 핸들러
-// 실사 의뢰 대화상자 열기 핸들러
-const handleOpenRequestDialog = async () => {
- setIsLoading(true);
- const initialData: InvestigationInitialData = {};
-
- try {
- // 선택된 행이 정확히 1개인 경우에만 초기값 설정
- if (selectedRows.length === 1) {
- const row = selectedRows[0].original;
-
- // 승인된 PQ이고 아직 실사가 없는 경우
- if (row.status === "APPROVED" && !row.investigation) {
- // Factory Location 정보 가져오기
- const locationResponse = await getFactoryLocationAnswer(
- row.vendorId,
- row.projectId
- );
-
- // 기본 주소 설정 - Factory Location 응답 또는 fallback
- let defaultAddress = "";
- if (locationResponse.success && locationResponse.factoryLocation) {
- defaultAddress = locationResponse.factoryLocation;
- } else {
- // Factory Location을 찾지 못한 경우 fallback
- defaultAddress = row.taxId ?
- `${row.vendorName} 사업장 (${row.taxId})` :
- `${row.vendorName} 사업장`;
- }
-
- // 이미 같은 회사에 대한 다른 실사가 있는지 확인
- const existingInvestigations = table.getFilteredRowModel().rows
- .map(r => r.original)
- .filter(r =>
- r.vendorId === row.vendorId &&
- r.investigation !== null
- );
-
- // 같은 업체의 이전 실사 기록이 있다면 참고하되, 주소는 Factory Location 사용
- if (existingInvestigations.length > 0) {
- // 날짜 기준으로 정렬하여 가장 최근 것을 가져옴
- const latestInvestigation = existingInvestigations.sort((a, b) => {
- const dateA = a.investigation?.createdAt || new Date(0);
- const dateB = b.investigation?.createdAt || new Date(0);
- return (dateB as Date).getTime() - (dateA as Date).getTime();
- })[0].investigation;
-
- if (latestInvestigation) {
- initialData.evaluationType = latestInvestigation.evaluationType || "SITE_AUDIT";
- initialData.qmManagerId = latestInvestigation.qmManagerId || undefined;
- initialData.investigationAddress = defaultAddress; // Factory Location 사용
-
- // 날짜는 미래로 설정
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후
- initialData.forecastedAt = futureDate;
- }
- } else {
- // 기본값 설정
- initialData.evaluationType = "SITE_AUDIT";
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후
- initialData.forecastedAt = futureDate;
- initialData.investigationAddress = defaultAddress; // Factory Location 사용
- }
- }
- // 실사가 이미 있고 수정하는 경우
- else if (row.investigation) {
- initialData.evaluationType = row.investigation.evaluationType || "SITE_AUDIT";
- initialData.qmManagerId = row.investigation.qmManagerId !== null ?
- row.investigation.qmManagerId : undefined;
- initialData.forecastedAt = row.investigation.forecastedAt || new Date();
- initialData.investigationAddress = row.investigation.investigationAddress || "";
- initialData.investigationNotes = row.investigation.investigationNotes || "";
- }
- }
- } catch (error) {
- console.error("초기 데이터 로드 중 오류:", error);
- toast.error("초기 데이터 로드 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
-
- // 초기 데이터 설정 및 대화상자 열기
- setDialogInitialData(Object.keys(initialData).length > 0 ? initialData : undefined);
- setIsRequestDialogOpen(true);
- }
-};
- // 실사 의뢰 요청 처리
- const handleRequestInvestigation = async (formData: {
- evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT",
- qmManagerId: number,
- forecastedAt: Date,
- investigationAddress: string,
- investigationNotes?: string
- }) => {
- setIsLoading(true)
- try {
- // 승인된 PQ 제출만 필터링
- const approvedPQs = selectedRows.filter(row =>
- row.original.status === "APPROVED" && !row.original.investigation
- )
-
- if (approvedPQs.length === 0) {
- toast.error("실사를 의뢰할 수 있는 업체가 없습니다. 승인된 PQ 제출만 실사 의뢰가 가능합니다.")
- return
- }
-
- // 서버 액션 호출
- const result = await requestInvestigationAction(
- approvedPQs.map(row => row.original.id),
- formData
- )
-
- if (result.success) {
- toast.success(`${result.count}개 업체에 대한 ${formData.evaluationType === "SITE_AUDIT" ? "실사의뢰평가" : "QM자체평가"}가 의뢰되었습니다.`)
- window.location.reload()
- } else {
- toast.error(result.error || "실사 의뢰 처리 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("실사 의뢰 중 오류 발생:", error)
- toast.error("실사 의뢰 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- setIsRequestDialogOpen(false)
- setDialogInitialData(undefined); // 초기 데이터 초기화
- }
- }
-
- const handleCloseRequestDialog = () => {
- setIsRequestDialogOpen(false);
- setDialogInitialData(undefined);
- };
-
-
- // 실사 의뢰 취소 처리
- const handleCancelInvestigation = async () => {
- setIsLoading(true)
- try {
- // 실사가 계획됨 상태인 PQ만 필터링
- const plannedInvestigations = selectedRows.filter(row =>
- row.original.investigation &&
- row.original.investigation.investigationStatus === "PLANNED"
- )
-
- if (plannedInvestigations.length === 0) {
- toast.error("취소할 수 있는 실사 의뢰가 없습니다. 계획 상태의 실사만 취소할 수 있습니다.")
- return
- }
-
- // 서버 액션 호출
- const result = await cancelInvestigationAction(
- plannedInvestigations.map(row => row.original.investigation!.id)
- )
-
- if (result.success) {
- toast.success(`${result.count}개 업체에 대한 실사 의뢰가 취소되었습니다.`)
- window.location.reload()
- } else {
- toast.error(result.error || "실사 취소 처리 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("실사 의뢰 취소 중 오류 발생:", error)
- toast.error("실사 의뢰 취소 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- setIsCancelDialogOpen(false)
- }
- }
-
- // 실사 결과 발송 처리
- const handleSendInvestigationResults = async () => {
- setIsLoading(true)
- try {
- // 완료된 실사만 필터링
- const completedInvestigations = selectedRows.filter(row =>
- row.original.investigation &&
- row.original.investigation.investigationStatus === "COMPLETED"
- )
-
- if (completedInvestigations.length === 0) {
- toast.error("발송할 실사 결과가 없습니다. 완료된 실사만 결과를 발송할 수 있습니다.")
- return
- }
-
- // 서버 액션 호출
- const result = await sendInvestigationResultsAction(
- completedInvestigations.map(row => row.original.investigation!.id)
- )
-
- if (result.success) {
- toast.success(`${result.count}개 업체에 대한 실사 결과가 발송되었습니다.`)
- window.location.reload()
- } else {
- toast.error(result.error || "실사 결과 발송 처리 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("실사 결과 발송 중 오류 발생:", error)
- toast.error("실사 결과 발송 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- setIsSendResultsDialogOpen(false)
- }
- }
-
- // 승인된 업체 수 확인
- const approvedPQsCount = selectedRows.filter(row =>
- row.original.status === "APPROVED" && !row.original.investigation
- ).length
-
- // 계획 상태 실사 수 확인
- const plannedInvestigationsCount = selectedRows.filter(row =>
- row.original.investigation &&
- row.original.investigation.investigationStatus === "PLANNED"
- ).length
-
- // 완료된 실사 수 확인
- const completedInvestigationsCount = selectedRows.filter(row =>
- row.original.investigation &&
- row.original.investigation.investigationStatus === "COMPLETED"
- ).length
-
- return (
- <>
- <div className="flex items-center gap-2">
- {/* 실사 의뢰 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용
- disabled={isLoading || selectedRows.length === 0}
- className="gap-2"
- >
- <ClipboardCheck className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">실사 의뢰</span>
- </Button>
-
- {/* 실사 의뢰 취소 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelDialogOpen(true)}
- disabled={isLoading || selectedRows.length === 0}
- className="gap-2"
- >
- <X className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">실사 취소</span>
- </Button>
-
- {/* 실사 결과 발송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsSendResultsDialogOpen(true)}
- disabled={isLoading || selectedRows.length === 0}
- className="gap-2"
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">결과 발송</span>
- </Button>
-
- {/** Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors-pq-submissions",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
-
- {/* 실사 의뢰 Dialog */}
- <RequestInvestigationDialog
- isOpen={isRequestDialogOpen}
- onClose={handleCloseRequestDialog} // 새로운 핸들러로 변경
- onSubmit={handleRequestInvestigation}
- selectedCount={approvedPQsCount}
- initialData={dialogInitialData} // 초기 데이터 전달
- />
-
-
- {/* 실사 취소 Dialog */}
- <CancelInvestigationDialog
- isOpen={isCancelDialogOpen}
- onClose={() => setIsCancelDialogOpen(false)}
- onConfirm={handleCancelInvestigation}
- selectedCount={plannedInvestigationsCount}
- />
-
- {/* 결과 발송 Dialog */}
- <SendResultsDialog
- isOpen={isSendResultsDialogOpen}
- onClose={() => setIsSendResultsDialogOpen(false)}
- onConfirm={handleSendInvestigationResults}
- selectedCount={completedInvestigationsCount}
- />
- </>
- )
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, ClipboardCheck, X, Send } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { PQSubmission } from "./vendors-table-columns"
+import {
+ requestInvestigationAction,
+ cancelInvestigationAction,
+ sendInvestigationResultsAction,
+ getFactoryLocationAnswer
+} from "@/lib/pq/service"
+import { RequestInvestigationDialog } from "./request-investigation-dialog"
+import { CancelInvestigationDialog } from "./cancel-investigation-dialog"
+import { SendResultsDialog } from "./send-results-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<PQSubmission>
+}
+
+interface InvestigationInitialData {
+ evaluationType?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL";
+ qmManagerId?: number;
+ forecastedAt?: Date;
+ createdAt?: Date;
+ investigationAddress?: string;
+ investigationNotes?: string;
+}
+
+export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // Dialog 상태 관리
+ const [isRequestDialogOpen, setIsRequestDialogOpen] = React.useState(false)
+ const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false)
+ const [isSendResultsDialogOpen, setIsSendResultsDialogOpen] = React.useState(false)
+
+ // 초기 데이터 상태
+ const [dialogInitialData, setDialogInitialData] = React.useState<InvestigationInitialData | undefined>(undefined)
+
+ // 실사 의뢰 대화상자 열기 핸들러
+// 실사 의뢰 대화상자 열기 핸들러
+const handleOpenRequestDialog = async () => {
+ setIsLoading(true);
+ const initialData: InvestigationInitialData = {};
+
+ try {
+ // 선택된 행이 정확히 1개인 경우에만 초기값 설정
+ if (selectedRows.length === 1) {
+ const row = selectedRows[0].original;
+
+ // 승인된 PQ이고 아직 실사가 없는 경우
+ if (row.status === "APPROVED" && !row.investigation) {
+ // Factory Location 정보 가져오기
+ const locationResponse = await getFactoryLocationAnswer(
+ row.vendorId,
+ row.projectId
+ );
+
+ // 기본 주소 설정 - Factory Location 응답 또는 fallback
+ let defaultAddress = "";
+ if (locationResponse.success && locationResponse.factoryLocation) {
+ defaultAddress = locationResponse.factoryLocation;
+ } else {
+ // Factory Location을 찾지 못한 경우 fallback
+ defaultAddress = row.taxId ?
+ `${row.vendorName} 사업장 (${row.taxId})` :
+ `${row.vendorName} 사업장`;
+ }
+
+ // 이미 같은 회사에 대한 다른 실사가 있는지 확인
+ const existingInvestigations = table.getFilteredRowModel().rows
+ .map(r => r.original)
+ .filter(r =>
+ r.vendorId === row.vendorId &&
+ r.investigation !== null
+ );
+
+ // 같은 업체의 이전 실사 기록이 있다면 참고하되, 주소는 Factory Location 사용
+ if (existingInvestigations.length > 0) {
+ // 날짜 기준으로 정렬하여 가장 최근 것을 가져옴
+ const latestInvestigation = existingInvestigations.sort((a, b) => {
+ const dateA = a.investigation?.createdAt || new Date(0);
+ const dateB = b.investigation?.createdAt || new Date(0);
+ return (dateB as Date).getTime() - (dateA as Date).getTime();
+ })[0].investigation;
+
+ if (latestInvestigation) {
+ initialData.evaluationType = latestInvestigation.evaluationType || "SITE_VISIT_EVAL";
+ initialData.qmManagerId = latestInvestigation.qmManagerId || undefined;
+ initialData.investigationAddress = defaultAddress; // Factory Location 사용
+
+ // 날짜는 미래로 설정
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후
+ initialData.forecastedAt = futureDate;
+ }
+ } else {
+ // 기본값 설정
+ initialData.evaluationType = "SITE_VISIT_EVAL";
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후
+ initialData.forecastedAt = futureDate;
+ initialData.investigationAddress = defaultAddress; // Factory Location 사용
+ }
+ }
+ // 실사가 이미 있고 수정하는 경우
+ else if (row.investigation) {
+ initialData.evaluationType = row.investigation.evaluationType || "SITE_VISIT_EVAL";
+ initialData.qmManagerId = row.investigation.qmManagerId !== null ?
+ row.investigation.qmManagerId : undefined;
+ initialData.forecastedAt = row.investigation.forecastedAt || new Date();
+ initialData.investigationAddress = row.investigation.investigationAddress || "";
+ initialData.investigationNotes = row.investigation.investigationNotes || "";
+ }
+ }
+ } catch (error) {
+ console.error("초기 데이터 로드 중 오류:", error);
+ toast.error("초기 데이터 로드 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+
+ // 초기 데이터 설정 및 대화상자 열기
+ setDialogInitialData(Object.keys(initialData).length > 0 ? initialData : undefined);
+ setIsRequestDialogOpen(true);
+ }
+};
+ // 실사 의뢰 요청 처리
+ const handleRequestInvestigation = async (formData: {
+ evaluationType: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationMethod?: string,
+ investigationNotes?: string
+ }) => {
+ setIsLoading(true)
+ try {
+ // 승인된 PQ 제출만 필터링
+ const approvedPQs = selectedRows.filter(row =>
+ row.original.status === "APPROVED" && !row.original.investigation
+ )
+
+ if (approvedPQs.length === 0) {
+ toast.error("실사를 의뢰할 수 있는 업체가 없습니다. 승인된 PQ 제출만 실사 의뢰가 가능합니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await requestInvestigationAction(
+ approvedPQs.map(row => row.original.id),
+ formData
+ )
+
+ if (result.success) {
+ const evaluationTypeLabels = {
+ "PURCHASE_SELF_EVAL": "구매자체평가",
+ "DOCUMENT_EVAL": "서류평가",
+ "PRODUCT_INSPECTION": "제품검사평가",
+ "SITE_VISIT_EVAL": "방문실사평가"
+ };
+ toast.success(`${result.count}개 업체에 대한 ${evaluationTypeLabels[formData.evaluationType]}가 의뢰되었습니다.`)
+ window.location.reload()
+ } else {
+ toast.error(result.error || "실사 의뢰 처리 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 의뢰 중 오류 발생:", error)
+ toast.error("실사 의뢰 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ setIsRequestDialogOpen(false)
+ setDialogInitialData(undefined); // 초기 데이터 초기화
+ }
+ }
+
+ const handleCloseRequestDialog = () => {
+ setIsRequestDialogOpen(false);
+ setDialogInitialData(undefined);
+ };
+
+
+ // 실사 의뢰 취소 처리
+ const handleCancelInvestigation = async () => {
+ setIsLoading(true)
+ try {
+ // 실사가 계획됨 상태인 PQ만 필터링
+ const plannedInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "PLANNED"
+ )
+
+ if (plannedInvestigations.length === 0) {
+ toast.error("취소할 수 있는 실사 의뢰가 없습니다. 계획 상태의 실사만 취소할 수 있습니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await cancelInvestigationAction(
+ plannedInvestigations.map(row => row.original.investigation!.id)
+ )
+
+ if (result.success) {
+ toast.success(`${result.count}개 업체에 대한 실사 의뢰가 취소되었습니다.`)
+ window.location.reload()
+ } else {
+ toast.error(result.error || "실사 취소 처리 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 의뢰 취소 중 오류 발생:", error)
+ toast.error("실사 의뢰 취소 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ setIsCancelDialogOpen(false)
+ }
+ }
+
+ // 실사 결과 발송 처리
+ const handleSendInvestigationResults = async (data: { purchaseComment?: string }) => {
+ try {
+ setIsLoading(true)
+
+ // 완료된 실사 중 승인된 결과만 필터링
+ const approvedInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED" &&
+ row.original.investigation.evaluationResult === "APPROVED"
+ )
+
+ if (approvedInvestigations.length === 0) {
+ toast.error("발송할 실사 결과가 없습니다. 완료되고 승인된 실사만 결과를 발송할 수 있습니다.")
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await sendInvestigationResultsAction({
+ investigationIds: approvedInvestigations.map(row => row.original.investigation!.id),
+ purchaseComment: data.purchaseComment,
+ })
+
+ if (result.success) {
+ toast.success(result.message || `${result.data?.successCount || 0}개 업체에 대한 실사 결과가 발송되었습니다.`)
+ window.location.reload()
+ } else {
+ toast.error(result.error || "실사 결과 발송 처리 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 결과 발송 중 오류 발생:", error)
+ toast.error("실사 결과 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ setIsSendResultsDialogOpen(false)
+ }
+ }
+
+ // 승인된 업체 수 확인
+ const approvedPQsCount = selectedRows.filter(row =>
+ row.original.status === "APPROVED" && !row.original.investigation
+ ).length
+
+ // 계획 상태 실사 수 확인
+ const plannedInvestigationsCount = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "PLANNED"
+ ).length
+
+ // 완료된 실사 수 확인 (승인된 결과만)
+ const completedInvestigationsCount = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED" &&
+ row.original.investigation.evaluationResult === "APPROVED"
+ ).length
+
+ // 실사 방법 라벨 변환 함수
+ const getInvestigationMethodLabel = (method: string): string => {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method
+ }
+ }
+
+ // 실사 결과 발송용 데이터 준비
+ const auditResults = selectedRows
+ .filter(row =>
+ row.original.investigation &&
+ row.original.investigation.investigationStatus === "COMPLETED" &&
+ row.original.investigation.evaluationResult === "APPROVED"
+ )
+ .map(row => {
+ const investigation = row.original.investigation!
+ const pqSubmission = row.original
+
+ return {
+ id: investigation.id,
+ vendorCode: row.original.vendorCode || "N/A",
+ vendorName: row.original.vendorName || "N/A",
+ vendorEmail: row.original.email || "N/A",
+ pqNumber: pqSubmission.pqNumber || "N/A",
+ auditItem: pqSubmission.pqItems || pqSubmission.projectName || "N/A",
+ auditFactoryAddress: investigation.investigationAddress || "N/A",
+ auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
+ auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
+ investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" :
+ investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A",
+ additionalNotes: investigation.investigationNotes,
+ investigationNotes: investigation.investigationNotes,
+ }
+ })
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 실사 의뢰 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용
+ disabled={isLoading || selectedRows.length === 0}
+ className="gap-2"
+ >
+ <ClipboardCheck className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">실사 의뢰</span>
+ </Button>
+
+ {/* 실사 의뢰 취소 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelDialogOpen(true)}
+ disabled={isLoading || selectedRows.length === 0}
+ className="gap-2"
+ >
+ <X className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">실사 취소</span>
+ </Button>
+
+ {/* 실사 결과 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsSendResultsDialogOpen(true)}
+ disabled={isLoading || selectedRows.length === 0}
+ className="gap-2"
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">결과 발송</span>
+ </Button>
+
+ {/** Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors-pq-submissions",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+
+ {/* 실사 의뢰 Dialog */}
+ <RequestInvestigationDialog
+ isOpen={isRequestDialogOpen}
+ onClose={handleCloseRequestDialog} // 새로운 핸들러로 변경
+ onSubmit={handleRequestInvestigation}
+ selectedCount={approvedPQsCount}
+ initialData={dialogInitialData} // 초기 데이터 전달
+ />
+
+
+ {/* 실사 취소 Dialog */}
+ <CancelInvestigationDialog
+ isOpen={isCancelDialogOpen}
+ onClose={() => setIsCancelDialogOpen(false)}
+ onConfirm={handleCancelInvestigation}
+ selectedCount={plannedInvestigationsCount}
+ />
+
+ {/* 결과 발송 Dialog */}
+ <SendResultsDialog
+ isOpen={isSendResultsDialogOpen}
+ onClose={() => setIsSendResultsDialogOpen(false)}
+ onConfirm={handleSendInvestigationResults}
+ selectedCount={completedInvestigationsCount}
+ auditResults={auditResults}
+ />
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/vendors-table.tsx b/lib/pq/pq-review-table-new/vendors-table.tsx
index e1c4cefe..c2712611 100644
--- a/lib/pq/pq-review-table-new/vendors-table.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table.tsx
@@ -1,308 +1,466 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-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 { getPQSubmissions } from "../service"
-import { getColumns, PQSubmission } from "./vendors-table-columns"
-import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
-import { PQFilterSheet } from "./pq-filter-sheet"
-import { cn } from "@/lib/utils"
-// TablePresetManager 관련 import 추가
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { useMemo } from "react"
-
-interface PQSubmissionsTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]>
- className?: string
-}
-
-export function PQSubmissionsTable({ promises, className }: PQSubmissionsTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQSubmission> | null>(null)
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
-
- const router = useRouter()
- const searchParams = useSearchParams()
-
- // Container wrapper의 위치를 측정하기 위한 ref
- const containerRef = React.useRef<HTMLDivElement>(null)
- const [containerTop, setContainerTop] = React.useState(0)
-
- // Container 위치 측정 함수 - top만 측정
- const updateContainerBounds = React.useCallback(() => {
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- setContainerTop(rect.top)
- }
- }, [])
-
- // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트
- React.useEffect(() => {
- updateContainerBounds()
-
- const handleResize = () => {
- updateContainerBounds()
- }
-
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', updateContainerBounds)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', updateContainerBounds)
- }
- }, [updateContainerBounds])
-
- // Suspense 방식으로 데이터 처리
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- // 디버깅용 로그
- console.log("PQ Table Data:", {
- dataLength: tableData.data?.length,
- pageCount: tableData.pageCount,
- sampleData: tableData.data?.[0]
- })
-
- // 초기 설정 정의 (RFQ와 동일한 패턴)
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams.get('page') || '1'),
- perPage: parseInt(searchParams.get('perPage') || '10'),
- sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') || searchParams.get('pqBasicFilters') ?
- JSON.parse(searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
- from: searchParams.get('from') || undefined,
- to: searchParams.get('to') || undefined,
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] }, // PQ는 actions를 오른쪽에 고정
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- // DB 기반 프리셋 훅 사용
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- updateClientState,
- getCurrentSettings,
- } = useTablePresets<PQSubmission>('pq-submissions-table', initialSettings)
-
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router }),
- [setRowAction, router]
- )
-
- // PQ 제출 필터링을 위한 필드 정의
- const filterFields: DataTableFilterField<PQSubmission>[] = [
- { id: "vendorName", label: "협력업체" },
- { id: "projectName", label: "프로젝트" },
- { id: "status", label: "상태" },
- ]
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<PQSubmission>[] = [
- { id: "requesterName", label: "요청자명", type: "text" },
- { id: "pqNumber", label: "PQ 번호", type: "text" },
- { id: "vendorName", label: "협력업체명", type: "text" },
- { id: "vendorCode", label: "협력업체 코드", type: "text" },
- { id: "type", label: "PQ 유형", type: "select", options: [
- { label: "일반 PQ", value: "GENERAL" },
- { label: "프로젝트 PQ", value: "PROJECT" },
- ]},
- { id: "projectName", label: "프로젝트명", type: "text" },
- { id: "status", label: "PQ 상태", type: "select", options: [
- { label: "요청됨", value: "REQUESTED" },
- { label: "진행 중", value: "IN_PROGRESS" },
- { label: "제출됨", value: "SUBMITTED" },
- { label: "승인됨", value: "APPROVED" },
- { label: "거부됨", value: "REJECTED" },
- ]},
- { id: "evaluationResult", label: "평가 결과", type: "select", options: [
- { label: "승인", value: "APPROVED" },
- { label: "보완", value: "SUPPLEMENT" },
- { label: "불가", value: "REJECTED" },
- ]},
- { id: "createdAt", label: "생성일", type: "date" },
- { id: "submittedAt", label: "제출일", type: "date" },
- { id: "approvedAt", label: "승인일", type: "date" },
- { id: "rejectedAt", label: "거부일", type: "date" },
- ]
-
- // 현재 설정 가져오기
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- // useDataTable 초기 상태 설정 (RFQ와 동일한 패턴)
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length, // total 추가
- filterFields, // RFQ와 달리 빈 배열이 아닌 실제 필터 필드 사용
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달
- const handleSearch = () => {
- // Close the panel after search
- setIsFilterPanelOpen(false)
- }
-
- // Get active basic filter count
- const getActiveBasicFilterCount = () => {
- try {
- const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
- }
- }
-
- // Filter panel width
- const FILTER_PANEL_WIDTH = 400;
-
- return (
- <>
- {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${containerTop}px`,
- height: `calc(100vh - ${containerTop}px)`
- }}
- >
- {/* Filter Content */}
- <div className="h-full">
- <PQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
- </div>
-
- {/* Main Content Container */}
- <div
- ref={containerRef}
- className={cn("relative w-full overflow-hidden", className)}
- >
- <div className="flex w-full h-full">
- {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */}
- <div
- className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
- }}
- >
- {/* Header Bar */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- {/* Right side info */}
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || tableData.data.length}건</span>
- )}
- </div>
- </div>
-
- {/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- {/* DB 기반 테이블 프리셋 매니저 추가 */}
- <TablePresetManager<PQSubmission>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- {/* 기존 툴바 액션들 */}
- <VendorsTableToolbarActions table={table} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
- </div>
- </div>
- </div>
- </>
- )
+"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import { toast } from "sonner"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { updateInvestigationDetailsAction } from "../service"
+import { createSiteVisitRequestAction, getSiteVisitRequestAction } from "@/lib/site-visit/service"
+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 { getPQSubmissions } from "../service"
+import { getColumns, PQSubmission } from "./vendors-table-columns"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { PQFilterSheet } from "./pq-filter-sheet"
+import { SiteVisitDialog } from "./site-visit-dialog"
+import { VendorInfoViewDialog } from "@/lib/site-visit/vendor-info-view-dialog"
+import { EditInvestigationDialog } from "./edit-investigation-dialog"
+import { cn } from "@/lib/utils"
+// TablePresetManager 관련 import 추가
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { TablePresetManager } from "@/components/data-table/data-table-preset"
+import { useMemo } from "react"
+
+interface PQSubmissionsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]>
+ className?: string
+}
+
+export function PQSubmissionsTable({ promises, className }: PQSubmissionsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQSubmission> | null>(null)
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // 방문실사 다이얼로그 상태
+ const [isSiteVisitDialogOpen, setIsSiteVisitDialogOpen] = React.useState(false)
+ const [selectedInvestigation, setSelectedInvestigation] = React.useState<PQSubmission | null>(null)
+ const [isVendorInfoViewDialogOpen, setIsVendorInfoViewDialogOpen] = React.useState(false)
+ const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState<number | null>(null)
+
+ // 실사 정보 수정 다이얼로그 상태
+ const [isEditInvestigationDialogOpen, setIsEditInvestigationDialogOpen] = React.useState(false)
+ const [selectedInvestigationForEdit, setSelectedInvestigationForEdit] = React.useState<PQSubmission | null>(null)
+
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ // Container wrapper의 위치를 측정하기 위한 ref
+ const containerRef = React.useRef<HTMLDivElement>(null)
+ const [containerTop, setContainerTop] = React.useState(0)
+
+ // Container 위치 측정 함수 - top만 측정
+ const updateContainerBounds = React.useCallback(() => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect()
+ setContainerTop(rect.top)
+ }
+ }, [])
+
+ // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트
+ React.useEffect(() => {
+ updateContainerBounds()
+
+ const handleResize = () => {
+ updateContainerBounds()
+ }
+
+ window.addEventListener('resize', handleResize)
+ window.addEventListener('scroll', updateContainerBounds)
+
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ window.removeEventListener('scroll', updateContainerBounds)
+ }
+ }, [updateContainerBounds])
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ // 디버깅용 로그
+ console.log("PQ Table Data:", {
+ dataLength: tableData.data?.length,
+ pageCount: tableData.pageCount,
+ sampleData: tableData.data?.[0]
+ })
+
+ // 방문실사 다이얼로그 핸들러
+ const handleSiteVisitRequest = async (data: {
+ inspectionDuration: number
+ requestedStartDate: Date
+ requestedEndDate: Date
+ shiAttendees: Record<string, boolean>
+ shiAttendeeDetails?: string
+ vendorRequests: Record<string, boolean>
+ otherVendorRequests?: string
+ additionalRequests?: string
+ }, attachments?: File[]) => {
+ try {
+ const result = await createSiteVisitRequestAction({
+ investigationId: selectedInvestigation?.investigation?.id || 0,
+ ...data,
+ attachments
+ })
+
+ if (result.success) {
+ toast.success(result.message || "방문실사 요청이 성공적으로 발송되었습니다.")
+ handleCloseSiteVisitDialog()
+ } else {
+ toast.error(result.error || "방문실사 요청 발송 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("방문실사 요청 오류:", error)
+ toast.error("방문실사 요청 발송 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 방문실사 다이얼로그 열기
+ const handleOpenSiteVisitDialog = async (investigation: PQSubmission) => {
+ try {
+ // 기존 방문실사 요청이 있는지 확인
+ const existingRequest = await getSiteVisitRequestAction(investigation.investigation?.id || 0)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ return
+ }
+
+ setSelectedInvestigation(investigation)
+ setIsSiteVisitDialogOpen(true)
+ } catch (error) {
+ console.error("방문실사 요청 상태 확인 중 오류:", error)
+ toast.error("방문실사 요청 상태 확인 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 방문실사 다이얼로그 닫기
+ const handleCloseSiteVisitDialog = () => {
+ setIsSiteVisitDialogOpen(false)
+ setSelectedInvestigation(null)
+ }
+
+ // 실사 정보 수정 핸들러
+ const handleEditInvestigation = async (data: {
+ confirmedAt?: Date
+ evaluationResult?: "APPROVED" | "SUPPLEMENT" | "REJECTED"
+ investigationNotes?: string
+ }) => {
+ if (!selectedInvestigationForEdit?.investigation?.id) {
+ toast.error("실사 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ try {
+ const result = await updateInvestigationDetailsAction({
+ investigationId: selectedInvestigationForEdit.investigation.id,
+ ...data
+ })
+
+ if (result.success) {
+ toast.success(result.message || "실사 정보가 성공적으로 업데이트되었습니다.")
+ setIsEditInvestigationDialogOpen(false)
+ setSelectedInvestigationForEdit(null)
+ } else {
+ toast.error(result.error || "실사 정보 업데이트 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("실사 정보 업데이트 오류:", error)
+ toast.error("실사 정보 업데이트 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 실사 정보 수정 다이얼로그 닫기
+ const handleCloseEditInvestigationDialog = () => {
+ setIsEditInvestigationDialogOpen(false)
+ setSelectedInvestigationForEdit(null)
+ }
+
+ // rowAction 핸들러
+ React.useEffect(() => {
+ if (rowAction?.type === "site-visit") {
+ // 방문실사 다이얼로그 열기
+ handleOpenSiteVisitDialog(rowAction.row)
+ setRowAction(null)
+ } else if (rowAction?.type === "vendor-info-view") {
+ // 협력업체 정보 조회 다이얼로그 열기
+ setSelectedSiteVisitRequestId(rowAction.row.siteVisitRequestId || null)
+ setIsVendorInfoViewDialogOpen(true)
+ setRowAction(null)
+ } else if (rowAction?.type === "edit-investigation") {
+ // 실사 정보 수정 다이얼로그 열기
+ setSelectedInvestigationForEdit(rowAction.row)
+ setIsEditInvestigationDialogOpen(true)
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ // 초기 설정 정의 (RFQ와 동일한 패턴)
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams.get('page') || '1'),
+ perPage: parseInt(searchParams.get('perPage') || '10'),
+ sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams.get('basicFilters') || searchParams.get('pqBasicFilters') ?
+ JSON.parse(searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')!) : [],
+ basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams.get('search') || '',
+ from: searchParams.get('from') || undefined,
+ to: searchParams.get('to') || undefined,
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["actions"] }, // PQ는 actions를 오른쪽에 고정
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<PQSubmission>('pq-submissions-table', initialSettings)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ // PQ 제출 필터링을 위한 필드 정의
+ const filterFields: DataTableFilterField<PQSubmission>[] = [
+ { id: "vendorName", label: "협력업체" },
+ { id: "projectName", label: "프로젝트" },
+ { id: "status", label: "상태" },
+ ]
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<PQSubmission>[] = [
+ { id: "requesterName", label: "요청자명", type: "text" },
+ { id: "pqNumber", label: "PQ 번호", type: "text" },
+ { id: "vendorName", label: "협력업체명", type: "text" },
+ { id: "vendorCode", label: "협력업체 코드", type: "text" },
+ { id: "type", label: "PQ 유형", type: "select", options: [
+ { label: "일반 PQ", value: "GENERAL" },
+ { label: "프로젝트 PQ", value: "PROJECT" },
+ ]},
+ { id: "projectName", label: "프로젝트명", type: "text" },
+ { id: "status", label: "PQ 상태", type: "select", options: [
+ { label: "요청됨", value: "REQUESTED" },
+ { label: "진행 중", value: "IN_PROGRESS" },
+ { label: "제출됨", value: "SUBMITTED" },
+ { label: "승인됨", value: "APPROVED" },
+ { label: "거부됨", value: "REJECTED" },
+ ]},
+
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "submittedAt", label: "제출일", type: "date" },
+ { id: "approvedAt", label: "승인일", type: "date" },
+ { id: "rejectedAt", label: "거부일", type: "date" },
+ ]
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정 (RFQ와 동일한 패턴)
+ const initialState = useMemo(() => {
+ return {
+ sorting: initialSettings.sort.filter(sortItem => {
+ const columnExists = columns.some(col => col.accessorKey === sortItem.id)
+ return columnExists
+ }),
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ rowCount: tableData.total || tableData.data.length, // total 추가
+ filterFields, // RFQ와 달리 빈 배열이 아닌 실제 필터 필드 사용
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달
+ const handleSearch = () => {
+ // Close the panel after search
+ setIsFilterPanelOpen(false)
+ }
+
+ // Get active basic filter count
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch {
+ return 0
+ }
+ }
+
+ // Filter panel width
+ const FILTER_PANEL_WIDTH = 400;
+
+ return (
+ <>
+ {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${containerTop}px`,
+ height: `calc(100vh - ${containerTop}px)`
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <PQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content Container */}
+ <div
+ ref={containerRef}
+ className={cn("relative w-full overflow-hidden", className)}
+ >
+ <div className="flex w-full h-full">
+ {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */}
+ <div
+ className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
+ }}
+ >
+ {/* Header Bar */}
+ <div className="flex items-center justify-between p-4 bg-background shrink-0">
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || tableData.data.length}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Table Content Area */}
+ <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
+ <div className="h-full w-full">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ {/* DB 기반 테이블 프리셋 매니저 추가 */}
+ <TablePresetManager<PQSubmission>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ {/* 기존 툴바 액션들 */}
+ <VendorsTableToolbarActions table={table} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 방문실사 다이얼로그 */}
+ {selectedInvestigation && (
+ <SiteVisitDialog
+ isOpen={isSiteVisitDialogOpen}
+ onClose={handleCloseSiteVisitDialog}
+ onSubmit={handleSiteVisitRequest}
+ investigation={{
+ id: selectedInvestigation.investigation?.id || 0,
+ evaluationType: selectedInvestigation.investigation?.evaluationType as "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ investigationMethod: selectedInvestigation.investigation?.investigationMethod,
+ investigationAddress: selectedInvestigation.investigation?.investigationAddress,
+ vendorName: selectedInvestigation.vendorName,
+ vendorCode: selectedInvestigation.vendorCode,
+ projectName: selectedInvestigation.projectName || undefined,
+ projectCode: selectedInvestigation.projectCode || undefined,
+ pqItems: selectedInvestigation.pqItems,
+ }}
+ />
+ )}
+
+ {/* 협력업체 정보 조회 다이얼로그 */}
+ <VendorInfoViewDialog
+ isOpen={isVendorInfoViewDialogOpen}
+ onClose={() => {
+ setIsVendorInfoViewDialogOpen(false)
+ setSelectedSiteVisitRequestId(null)
+ }}
+ siteVisitRequestId={selectedSiteVisitRequestId}
+ />
+
+ {/* 실사 정보 수정 다이얼로그 */}
+ <EditInvestigationDialog
+ isOpen={isEditInvestigationDialogOpen}
+ onClose={handleCloseEditInvestigationDialog}
+ investigation={selectedInvestigationForEdit?.investigation || null}
+ onSubmit={handleEditInvestigation}
+ />
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table/vendors-table-columns.tsx b/lib/pq/pq-review-table/vendors-table-columns.tsx
index dfa1c44f..8673443f 100644
--- a/lib/pq/pq-review-table/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table/vendors-table-columns.tsx
@@ -160,12 +160,12 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
if (cfg.id === "createdAt") {
const dateVal = cell.getValue() as Date
- return formatDate(dateVal, "KR")
+ return formatDate(dateVal)
}
if (cfg.id === "updatedAt") {
const dateVal = cell.getValue() as Date
- return formatDate(dateVal, "KR")
+ return formatDate(dateVal)
}
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 18d1a5d3..ac1b9e87 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -1,2882 +1,3679 @@
-"use server"
-
-import db from "@/db/db"
-import { GetPQSchema, GetPQSubmissionsSchema } from "./validations"
-import { unstable_cache } from "@/lib/unstable-cache";
-import { filterColumns } from "@/lib/filter-columns";
-import { getErrorMessage } from "@/lib/handle-error";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL} from "drizzle-orm";
-import { z } from "zod"
-import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
-import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorInvestigations, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq"
-import { countPqs, selectPqs } from "./repository";
-import { sendEmail } from "../mail/sendEmail";
-import { vendorAttachments, vendors } from "@/db/schema/vendors";
-import path from 'path';
-import fs from 'fs/promises';
-import { randomUUID } from 'crypto';
-import { writeFile, mkdir } from 'fs/promises';
-import { GetVendorsSchema } from "../vendors/validations";
-import { countVendors, selectVendors } from "../vendors/repository";
-import { projects, users } from "@/db/schema";
-import { headers } from 'next/headers';
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { alias } from 'drizzle-orm/pg-core';
-import { createPQFilterMapping, getPQJoinedTables } from "./helper";
-
-/**
- * PQ 목록 조회
- */
-export async function getPQs(
- input: GetPQSchema,
- projectId?: number | null,
- onlyGeneral?: boolean
-) {
- return unstable_cache(
- async () => {
- try {
- // Common query building logic extracted to a helper function
- const buildBaseQuery = (queryBuilder: any) => {
- let query = queryBuilder.from(pqCriterias);
-
- // Handle join conditions based on parameters
- if (projectId) {
- query = query
- .innerJoin(
- pqCriteriasExtension,
- eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId)
- )
- .where(eq(pqCriteriasExtension.projectId, projectId));
- } else if (onlyGeneral) {
- query = query
- .leftJoin(
- pqCriteriasExtension,
- eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId)
- )
- .where(isNull(pqCriteriasExtension.id));
- }
-
- // Apply filters
- const advancedWhere = filterColumns({
- table: pqCriterias,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // Handle search
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(pqCriterias.code, s),
- ilike(pqCriterias.groupName, s),
- ilike(pqCriterias.remarks, s),
- ilike(pqCriterias.checkPoint, s),
- ilike(pqCriterias.description, s)
- );
- }
-
- // Combine where clauses
- const finalWhere = and(advancedWhere, globalWhere);
- if (finalWhere) {
- query = query.where(finalWhere);
- }
-
- return { query, finalWhere };
- };
-
- const offset = (input.page - 1) * input.perPage;
-
- // Build sort order configuration
- const orderBy = input.sort?.length > 0
- ? input.sort.map((item) =>
- item.desc
- ? desc(pqCriterias[item.id])
- : asc(pqCriterias[item.id])
- )
- : [asc(pqCriterias.createdAt)];
-
- // Execute in a transaction
- const { data, total } = await db.transaction(async (tx) => {
- // 변경: 쿼리 결과 형태를 변경하여 데이터가 평탄화되도록 수정
- // Data query
- const { query: baseQuery } = buildBaseQuery(tx.select({
- id: pqCriterias.id,
- code: pqCriterias.code,
- checkPoint: pqCriterias.checkPoint,
- description: pqCriterias.description,
- remarks: pqCriterias.remarks,
- groupName: pqCriterias.groupName,
- createdAt: pqCriterias.createdAt,
- updatedAt: pqCriterias.updatedAt,
- // 필요한 경우 pqCriteriasExtension의 필드도 여기에 추가
- }));
-
- const data = await baseQuery
- .orderBy(...orderBy)
- .offset(offset)
- .limit(input.perPage);
-
- // Count query - reusing the same base query logic
- const { query: countQuery } = buildBaseQuery(tx.select({ count: count() }));
- const countRes = await countQuery;
- const total = countRes[0]?.count ?? 0;
-
- return { data, total };
- });
-
- // Calculate page count
- const pageCount = Math.ceil(total / input.perPage);
-
- // 이미 평탄화된 객체 배열 형태로 반환됨
- return { data, pageCount };
- } catch (err) {
- console.log('Error in getPQs:', err);
- console.error('Error in getPQs:', err);
- throw new Error('Failed to fetch PQ criteria');
- }
- },
- [JSON.stringify(input), projectId?.toString() ?? 'undefined', onlyGeneral?.toString() ?? 'undefined'],
- {
- revalidate: 3600,
- tags: ["pq"],
- }
- )();
-}
-
-// PQ 생성을 위한 입력 스키마 정의
-const createPqSchema = z.object({
- code: z.string().min(1, "Code is required"),
- checkPoint: z.string().min(1, "Check point is required"),
- description: z.string().optional().nullable(),
- remarks: z.string().optional().nullable(),
- groupName: z.string().optional()
-});
-
-export interface CreatePqInputType extends z.infer<typeof createPqSchema> {
- projectId?: number | null;
- contractInfo?: string | null;
- additionalRequirement?: string | null;
-}
-
-/**
- * PQ 기준 생성
- */
-export async function createPq(input: CreatePqInputType) {
- try {
- // 기본 데이터 유효성 검증
- const validatedData = createPqSchema.parse(input);
-
- // 프로젝트 정보 및 확장 필드 확인
- const isProjectSpecific = !!input.projectId;
-
- // 트랜잭션 사용하여 PQ 기준 생성
- return await db.transaction(async (tx) => {
- // 1. 기본 PQ 기준 생성
- const [newPqCriteria] = await tx
- .insert(pqCriterias)
- .values({
- code: validatedData.code,
- checkPoint: validatedData.checkPoint,
- description: validatedData.description || null,
- remarks: validatedData.remarks || null,
- groupName: validatedData.groupName || null,
- })
- .returning({ id: pqCriterias.id });
-
- // 2. 프로젝트별 PQ인 경우 확장 테이블에도 데이터 추가
- if (isProjectSpecific && input.projectId) {
- await tx
- .insert(pqCriteriasExtension)
- .values({
- pqCriteriaId: newPqCriteria.id,
- projectId: input.projectId,
- contractInfo: input.contractInfo || null,
- additionalRequirement: input.additionalRequirement || null,
- });
- }
-
- // 성공 결과 반환
- return {
- success: true,
- pqId: newPqCriteria.id,
- isProjectSpecific,
- message: isProjectSpecific
- ? "Project-specific PQ criteria created successfully"
- : "General PQ criteria created successfully"
- };
- });
- } catch (error) {
- console.error("Error creating PQ criteria:", error);
-
- // Zod 유효성 검사 에러 처리
- if (error instanceof z.ZodError) {
- return {
- success: false,
- message: "Validation failed",
- errors: error.errors
- };
- }
-
- // 기타 에러 처리
- return {
- success: false,
- message: "Failed to create PQ criteria"
- };
- }
-}
-// PQ 캐시 무효화 함수
-export async function invalidatePqCache() {
- revalidatePath(`/evcp/pq-criteria`);
- revalidateTag(`pq`);
-}
-
-// PQ 삭제를 위한 스키마 정의
-const removePqsSchema = z.object({
- ids: z.array(z.number()).min(1, "At least one PQ ID is required")
-});
-
-export type RemovePqsInputType = z.infer<typeof removePqsSchema>;
-
-/**
- * PQ 기준 삭제
- */
-export async function removePqs(input: RemovePqsInputType) {
- try {
- // 입력 유효성 검증
- const validatedData = removePqsSchema.parse(input);
-
- // 트랜잭션 사용하여 PQ 기준 삭제
- await db.transaction(async (tx) => {
- // PQ 기준 테이블에서 삭제
- await tx
- .delete(pqCriterias)
- .where(inArray(pqCriterias.id, validatedData.ids));
- });
-
- // 캐시 무효화
- await invalidatePqCache();
-
- return { success: true };
- } catch (error) {
- console.error("Error removing PQ criteria:", error);
-
- // Zod 유효성 검사 에러 처리
- if (error instanceof z.ZodError) {
- return {
- success: false,
- error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
- };
- }
-
- // 기타 에러 처리
- return {
- success: false,
- error: "Failed to remove PQ criteria"
- };
- }
-}
-
-// PQ 수정을 위한 스키마 정의
-const modifyPqSchema = z.object({
- id: z.number().positive("ID is required"),
- code: z.string().min(1, "Code is required"),
- checkPoint: z.string().min(1, "Check point is required"),
- groupName: z.string().min(1, "Group is required"),
- description: z.string().optional(),
- remarks: z.string().optional()
-});
-
-export type ModifyPqInputType = z.infer<typeof modifyPqSchema>;
-
-
-export async function modifyPq(input: ModifyPqInputType) {
- try {
- // 입력 유효성 검증
- const validatedData = modifyPqSchema.parse(input);
-
- // 트랜잭션 사용하여 PQ 기준 수정
- return await db.transaction(async (tx) => {
- // PQ 기준 수정
- await tx
- .update(pqCriterias)
- .set({
- code: validatedData.code,
- checkPoint: validatedData.checkPoint,
- description: validatedData.description || null,
- remarks: validatedData.remarks || null,
- groupName: validatedData.groupName,
- updatedAt: new Date(),
- })
- .where(eq(pqCriterias.id, validatedData.id));
-
- // 성공 결과 반환
- return {
- success: true,
- message: "PQ criteria updated successfully"
- };
- });
- } catch (error) {
- console.error("Error updating PQ criteria:", error);
-
- // Zod 유효성 검사 에러 처리
- if (error instanceof z.ZodError) {
- return {
- success: false,
- error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
- };
- }
-
- // 기타 에러 처리
- return {
- success: false,
- error: "Failed to update PQ criteria"
- };
- } finally {
- // 캐시 무효화
- revalidatePath(`/partners/pq`);
- revalidateTag(`pq`);
- }
-}
-
-export interface PQAttachment {
- attachId: number
- fileName: string
- filePath: string
- fileSize?: number
-}
-
-export interface PQItem {
- answerId: number | null
- criteriaId: number
- code: string
- checkPoint: string
- description: string | null
- remarks?: string | null
- // 프로젝트 PQ 전용 필드
- contractInfo?: string | null
- additionalRequirement?: string | null
- answer: string
- attachments: PQAttachment[]
-}
-
-export interface PQGroupData {
- groupName: string
- items: PQItem[]
-}
-
-export interface ProjectPQ {
- id: number;
- projectId: number;
- status: string;
- submittedAt: Date | null;
- projectCode: string;
- projectName: string;
-}
-
-export async function getPQProjectsByVendorId(vendorId: number): Promise<ProjectPQ[]> {
- const result = await db
- .select({
- id: vendorPQSubmissions.id,
- projectId: vendorPQSubmissions.projectId,
- status: vendorPQSubmissions.status,
- submittedAt: vendorPQSubmissions.submittedAt,
- projectCode: projects.code,
- projectName: projects.name,
- })
- .from(vendorPQSubmissions)
- .innerJoin(
- projects,
- eq(vendorPQSubmissions.projectId, projects.id)
- )
- .where(eq(vendorPQSubmissions.vendorId, vendorId))
- .orderBy(projects.code);
-
- return result;
-}
-
-export async function getPQDataByVendorId(
- vendorId: number,
- projectId?: number
-): Promise<PQGroupData[]> {
- try {
- // 기본 쿼리 구성
- const selectObj = {
- criteriaId: pqCriterias.id,
- groupName: pqCriterias.groupName,
- code: pqCriterias.code,
- checkPoint: pqCriterias.checkPoint,
- description: pqCriterias.description,
- remarks: pqCriterias.remarks,
-
- // 프로젝트 PQ 추가 필드
- contractInfo: pqCriteriasExtension.contractInfo,
- additionalRequirement: pqCriteriasExtension.additionalRequirement,
-
- // 협력업체 응답 필드
- answer: vendorPqCriteriaAnswers.answer,
- answerId: vendorPqCriteriaAnswers.id,
-
- // 첨부 파일 필드
- attachId: vendorCriteriaAttachments.id,
- fileName: vendorCriteriaAttachments.fileName,
- filePath: vendorCriteriaAttachments.filePath,
- fileSize: vendorCriteriaAttachments.fileSize,
- };
-
- // Create separate queries for each case instead of modifying the same query variable
- if (projectId) {
- // 프로젝트별 PQ 쿼리
- const rows = await db
- .select(selectObj)
- .from(pqCriterias)
- .innerJoin(
- pqCriteriasExtension,
- and(
- eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId),
- eq(pqCriteriasExtension.projectId, projectId)
- )
- )
- .leftJoin(
- vendorPqCriteriaAnswers,
- and(
- eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- eq(vendorPqCriteriaAnswers.projectId, projectId)
- )
- )
- .leftJoin(
- vendorCriteriaAttachments,
- eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
- )
- .orderBy(pqCriterias.groupName, pqCriterias.code);
-
- return processQueryResults(rows);
- } else {
- // 일반 PQ 쿼리
- const rows = await db
- .select(selectObj)
- .from(pqCriterias)
- .leftJoin(
- pqCriteriasExtension,
- eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId)
- )
- .where(isNull(pqCriteriasExtension.id))
- .leftJoin(
- vendorPqCriteriaAnswers,
- and(
- eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- isNull(vendorPqCriteriaAnswers.projectId)
- )
- )
- .leftJoin(
- vendorCriteriaAttachments,
- eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
- )
- .orderBy(pqCriterias.groupName, pqCriterias.code);
-
- return processQueryResults(rows);
- }
- } catch (error) {
- console.error("Error fetching PQ data:", error);
- return [];
- }
-
- // Helper function to process query results
- function processQueryResults(rows: any[]) {
- // 그룹별로 데이터 구성
- const groupMap = new Map<string, Record<number, PQItem>>();
-
- for (const row of rows) {
- const g = row.groupName || "Others";
-
- // 그룹 확인
- if (!groupMap.has(g)) {
- groupMap.set(g, {});
- }
-
- const groupItems = groupMap.get(g)!;
-
- // 아직 이 기준을 처리하지 않았으면 PQItem 생성
- if (!groupItems[row.criteriaId]) {
- groupItems[row.criteriaId] = {
- answerId: row.answerId,
- criteriaId: row.criteriaId,
- code: row.code,
- checkPoint: row.checkPoint,
- description: row.description,
- remarks: row.remarks,
- // 프로젝트 PQ 전용 필드
- contractInfo: row.contractInfo,
- additionalRequirement: row.additionalRequirement,
- answer: row.answer || "",
- attachments: [],
- };
- }
-
- // 첨부 파일이 있으면 추가
- if (row.attachId) {
- groupItems[row.criteriaId].attachments.push({
- attachId: row.attachId,
- fileName: row.fileName || "",
- filePath: row.filePath || "",
- fileSize: row.fileSize || undefined,
- });
- }
- }
-
- // 최종 데이터 구성
- const data: PQGroupData[] = [];
- for (const [groupName, itemsMap] of groupMap.entries()) {
- const items = Object.values(itemsMap);
- data.push({ groupName, items });
- }
-
- return data;
- }
-}
-
-
-interface PQAttachmentInput {
- fileName: string // original user-friendly file name
- url: string // the UUID-based path stored on server
- size?: number // optional file size
-}
-
-interface SavePQAnswer {
- criteriaId: number
- answer: string
- attachments: PQAttachmentInput[]
-}
-
-interface SavePQInput {
- vendorId: number
- projectId?: number
- answers: SavePQAnswer[]
-}
-
-/**
- * 여러 항목을 한 번에 Upsert
- */
-export async function savePQAnswersAction(input: SavePQInput) {
- const { vendorId, projectId, answers } = input
-
- try {
- for (const ans of answers) {
- // 1) Check if a row already exists for (vendorId, criteriaId, projectId)
- const queryConditions = [
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId)
- ];
-
- // Add projectId condition when it exists
- if (projectId !== undefined) {
- queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
- } else {
- queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
- }
-
- const existing = await db
- .select()
- .from(vendorPqCriteriaAnswers)
- .where(and(...queryConditions));
-
- let answerId: number
-
- // 2) If it exists, update the row; otherwise insert
- if (existing.length === 0) {
- // Insert new
- const inserted = await db
- .insert(vendorPqCriteriaAnswers)
- .values({
- vendorId,
- criteriaId: ans.criteriaId,
- projectId: projectId || null, // Include projectId when provided
- answer: ans.answer,
- })
- .returning({ id: vendorPqCriteriaAnswers.id })
-
- answerId = inserted[0].id
- } else {
- // Update existing
- answerId = existing[0].id
-
- await db
- .update(vendorPqCriteriaAnswers)
- .set({
- answer: ans.answer,
- updatedAt: new Date(),
- })
- .where(eq(vendorPqCriteriaAnswers.id, answerId))
- }
-
- // 3) Now manage attachments in vendorCriteriaAttachments
- // 3a) Load old attachments from DB
- const oldAttachments = await db
- .select({
- id: vendorCriteriaAttachments.id,
- filePath: vendorCriteriaAttachments.filePath,
- })
- .from(vendorCriteriaAttachments)
- .where(eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, answerId))
-
- // 3b) Gather the new filePaths (urls) from the client
- const newPaths = ans.attachments.map(a => a.url)
-
- // 3c) Find attachments to remove
- const toRemove = oldAttachments.filter(old => !newPaths.includes(old.filePath))
- if (toRemove.length > 0) {
- const removeIds = toRemove.map(r => r.id)
- await db
- .delete(vendorCriteriaAttachments)
- .where(inArray(vendorCriteriaAttachments.id, removeIds))
- }
-
- // 3d) Insert new attachments that aren't in DB
- const oldPaths = oldAttachments.map(o => o.filePath)
- const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url))
-
- for (const attach of toAdd) {
- await db.insert(vendorCriteriaAttachments).values({
- vendorCriteriaAnswerId: answerId,
- fileName: attach.fileName,
- filePath: attach.url,
- fileSize: attach.size ?? null,
- })
- }
- }
-
- return { ok: true }
- } catch (error) {
- console.error("savePQAnswersAction error:", error)
- return { ok: false, error: String(error) }
- }
-}
-
-
-
-/**
- * PQ 제출 서버 액션 - 협력업체 상태를 PQ_SUBMITTED로 업데이트
- * @param vendorId 협력업체 ID
- */
-export async function submitPQAction({
- vendorId,
- projectId,
- pqSubmissionId
-}: {
- vendorId: number;
- projectId?: number;
- pqSubmissionId?: number; // 특정 PQ 제출 ID가 있는 경우 사용
-}) {
- unstable_noStore();
-
- try {
- const headersList = await headers();
- const host = headersList.get('host') || 'localhost:3000';
-
- // 1. 모든 PQ 항목에 대한 응답이 있는지 검증
- const answerQueryConditions = [
- eq(vendorPqCriteriaAnswers.vendorId, vendorId)
- ];
-
- // Add projectId condition when it exists
- if (projectId !== undefined) {
- answerQueryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
- } else {
- answerQueryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
- }
-
- const pqCriteriaCount = await db
- .select({ count: count() })
- .from(vendorPqCriteriaAnswers)
- .where(and(...answerQueryConditions));
-
- const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0;
-
- // 응답 데이터 검증
- if (totalPqCriteriaCount === 0) {
- return { ok: false, error: "No PQ answers found" };
- }
-
- // 2. 협력업체 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- status: vendors.status,
- })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .then(rows => rows[0]);
-
- if (!vendor) {
- return { ok: false, error: "Vendor not found" };
- }
-
- // Project 정보 조회 (projectId가 있는 경우)
- let projectName = '';
- if (projectId) {
- const projectData = await db
- .select({
- projectName: projects.name
- })
- .from(projects)
- .where(eq(projects.id, projectId))
- .then(rows => rows[0]);
-
- projectName = projectData?.projectName || 'Unknown Project';
- }
-
- // 3. 현재 PQ 제출 상태 확인 및 업데이트
- const currentDate = new Date();
- let existingSubmission;
-
- // 특정 PQ Submission ID가 있는 경우
- if (pqSubmissionId) {
- existingSubmission = await db
- .select({
- id: vendorPQSubmissions.id,
- status: vendorPQSubmissions.status,
- type: vendorPQSubmissions.type
- })
- .from(vendorPQSubmissions)
- .where(
- and(
- eq(vendorPQSubmissions.id, pqSubmissionId),
- eq(vendorPQSubmissions.vendorId, vendorId)
- )
- )
- .then(rows => rows[0]);
-
- if (!existingSubmission) {
- return { ok: false, error: "PQ submission not found or access denied" };
- }
- }
- // ID가 없는 경우 vendorId와 projectId로 조회
- else {
- const pqType = projectId ? "PROJECT" : "GENERAL";
-
- const submissionQueryConditions = [
- eq(vendorPQSubmissions.vendorId, vendorId),
- eq(vendorPQSubmissions.type, pqType)
- ];
-
- if (projectId) {
- submissionQueryConditions.push(eq(vendorPQSubmissions.projectId, projectId));
- } else {
- submissionQueryConditions.push(isNull(vendorPQSubmissions.projectId));
- }
-
- existingSubmission = await db
- .select({
- id: vendorPQSubmissions.id,
- status: vendorPQSubmissions.status,
- type: vendorPQSubmissions.type
- })
- .from(vendorPQSubmissions)
- .where(and(...submissionQueryConditions))
- .then(rows => rows[0]);
- }
-
- // 제출 가능한 상태 확인
- const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"];
-
- if (existingSubmission) {
- if (!allowedStatuses.includes(existingSubmission.status)) {
- return {
- ok: false,
- error: `Cannot submit PQ in current status: ${existingSubmission.status}`
- };
- }
-
- // 기존 제출 상태 업데이트
- await db
- .update(vendorPQSubmissions)
- .set({
- status: "SUBMITTED",
- submittedAt: currentDate,
- updatedAt: currentDate,
- })
- .where(eq(vendorPQSubmissions.id, existingSubmission.id));
- } else {
- // PQ Submission ID가 없고 기존 submission도 없는 경우 새로운 제출 생성
- const pqType = projectId ? "PROJECT" : "GENERAL";
- await db
- .insert(vendorPQSubmissions)
- .values({
- vendorId,
- projectId: projectId || null,
- type: pqType,
- status: "SUBMITTED",
- submittedAt: currentDate,
- createdAt: currentDate,
- updatedAt: currentDate,
- });
- }
-
- // 4. 일반 PQ인 경우 벤더 상태도 업데이트
- if (!projectId) {
- const allowedVendorStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
-
- if (allowedVendorStatuses.includes(vendor.status)) {
- await db
- .update(vendors)
- .set({
- status: "PQ_SUBMITTED",
- updatedAt: currentDate,
- })
- .where(eq(vendors.id, vendorId));
- }
- }
-
- // 5. 관리자에게 이메일 알림 발송
- if (process.env.ADMIN_EMAIL) {
- try {
- const emailSubject = projectId
- ? `[eVCP] Project PQ Submitted: ${vendor.vendorName} for ${projectName}`
- : `[eVCP] General PQ Submitted: ${vendor.vendorName}`;
-
- const adminUrl = `http://${host}/evcp/pq/${vendorId}/${existingSubmission?.id || ''}`;
-
- await sendEmail({
- to: process.env.ADMIN_EMAIL,
- subject: emailSubject,
- template: "pq-submitted-admin",
- context: {
- vendorName: vendor.vendorName,
- vendorId: vendor.id,
- projectId: projectId,
- projectName: projectName,
- isProjectPQ: !!projectId,
- submittedDate: currentDate.toLocaleString(),
- adminUrl,
- }
- });
- } catch (emailError) {
- console.error("Failed to send admin notification:", emailError);
- }
- }
-
- // 6. 벤더에게 확인 이메일 발송
- if (vendor.email) {
- try {
- const emailSubject = projectId
- ? `[eVCP] Project PQ Submission Confirmation for ${projectName}`
- : "[eVCP] General PQ Submission Confirmation";
-
- const portalUrl = `${host}/partners/pq`;
-
- await sendEmail({
- to: vendor.email,
- subject: emailSubject,
- template: "pq-submitted-vendor",
- context: {
- vendorName: vendor.vendorName,
- projectId: projectId,
- projectName: projectName,
- isProjectPQ: !!projectId,
- submittedDate: currentDate.toLocaleString(),
- portalUrl,
- }
- });
- } catch (emailError) {
- console.error("Failed to send vendor confirmation:", emailError);
- }
- }
-
- // 7. 캐시 무효화
- revalidateTag("vendors");
- revalidateTag("vendor-status-counts");
- revalidateTag(`vendor-pq-submissions-${vendorId}`);
-
- if (projectId) {
- revalidateTag(`project-pq-submissions-${projectId}`);
- revalidateTag(`project-vendors-${projectId}`);
- revalidateTag(`project-pq-${projectId}`);
- }
-
- return { ok: true };
- } catch (error) {
- console.error("PQ submit error:", error);
- return { ok: false, error: getErrorMessage(error) };
- }
-}
-
-/**
- * 향상된 파일 업로드 서버 액션
- * - 직접 파일 처리 (file 객체로 받음)
- * - 디렉토리 자동 생성
- * - 중복 방지를 위한 UUID 적용
- */
-export async function uploadFileAction(file: File) {
- unstable_noStore();
-
- try {
- // 파일 유효성 검사
- if (!file || file.size === 0) {
- throw new Error("Invalid file");
- }
-
- const maxSize = 6e8;
- if (file.size > maxSize) {
- throw new Error(`File size exceeds limit (${Math.round(maxSize / 1024 / 1024)}MB)`);
- }
-
- // 파일 확장자 가져오기
- const originalFilename = file.name;
- const fileExt = path.extname(originalFilename);
- const fileNameWithoutExt = path.basename(originalFilename, fileExt);
-
- // 저장 경로 설정
- const uploadDir = process.env.UPLOAD_DIR
- ? process.env.UPLOAD_DIR
- : path.join(process.cwd(), "public", "uploads")
- const datePrefix = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
- const targetDir = path.join(uploadDir, 'pq', datePrefix);
-
- // UUID로 고유 파일명 생성
- const uuid = randomUUID();
- const sanitizedFilename = fileNameWithoutExt
- .replace(/[^a-zA-Z0-9-_]/g, '_') // 안전한 문자만 허용
- .slice(0, 50); // 이름 길이 제한
-
- const filename = `${sanitizedFilename}-${uuid}${fileExt}`;
- const filePath = path.join(targetDir, filename);
- const relativeFilePath = path.join('pq', datePrefix, filename);
-
- // 디렉토리 생성 (없는 경우)
- try {
- await mkdir(targetDir, { recursive: true });
- } catch (err) {
- console.error("Error creating directory:", err);
- throw new Error("Failed to create upload directory");
- }
-
- // 파일 저장
- const buffer = await file.arrayBuffer();
- await writeFile(filePath, Buffer.from(buffer));
-
- // 상대 경로를 반환 (DB에 저장하기 용이함)
- const publicUrl = `/uploads/${relativeFilePath.replace(/\\/g, '/')}`;
-
- return {
- fileName: originalFilename,
- url: publicUrl,
- size: file.size,
- };
- } catch (error) {
- console.error("File upload error:", error);
- throw new Error(`Upload failed: ${getErrorMessage(error)}`);
- }
-}
-
-/**
- * 여러 파일 일괄 업로드
- */
-export async function uploadMultipleFilesAction(files: File[]) {
- unstable_noStore();
-
- try {
- const results = [];
-
- for (const file of files) {
- try {
- const result = await uploadFileAction(file);
- results.push({
- success: true,
- ...result
- });
- } catch (error) {
- results.push({
- success: false,
- fileName: file.name,
- error: getErrorMessage(error)
- });
- }
- }
-
- return {
- ok: true,
- results
- };
- } catch (error) {
- console.error("Batch upload error:", error);
- return {
- ok: false,
- error: getErrorMessage(error)
- };
- }
-}
-
-export async function getVendorsInPQ(input: GetVendorsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터
- const advancedWhere = filterColumns({
- table: vendors,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 2) 글로벌 검색
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(vendors.vendorName, s),
- ilike(vendors.vendorCode, s),
- ilike(vendors.email, s),
- ilike(vendors.status, s)
- );
- }
-
- // 트랜잭션 내에서 데이터 조회
- const { data, total } = await db.transaction(async (tx) => {
- // 협력업체 ID 모음 (중복 제거용)
- const vendorIds = new Set<number>();
-
- // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이)
- const generalPqVendors = await tx
- .select({
- vendorId: vendorPqCriteriaAnswers.vendorId
- })
- .from(vendorPqCriteriaAnswers)
- .innerJoin(
- vendors,
- eq(vendorPqCriteriaAnswers.vendorId, vendors.id)
- )
- .where(
- and(
- isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님)
- advancedWhere,
- globalWhere
- )
- )
- .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트
-
- generalPqVendors.forEach(v => vendorIds.add(v.vendorId));
-
- // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이)
- const projectPqVendors = await tx
- .select({
- vendorId: vendorProjectPQs.vendorId
- })
- .from(vendorProjectPQs)
- .innerJoin(
- vendors,
- eq(vendorProjectPQs.vendorId, vendors.id)
- )
- .where(
- and(
- // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함
- not(eq(vendorProjectPQs.status, "REQUESTED")), // REQUESTED 상태는 제외
- advancedWhere,
- globalWhere
- )
- );
-
- projectPqVendors.forEach(v => vendorIds.add(v.vendorId));
-
- // 중복 제거된 협력업체 ID 배열
- const uniqueVendorIds = Array.from(vendorIds);
-
- // 총 개수 (중복 제거 후)
- const total = uniqueVendorIds.length;
-
- if (total === 0) {
- return { data: [], total: 0 };
- }
-
- // 페이징 처리 (정렬 후 limit/offset 적용)
- const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage);
-
- // 2) 페이징된 협력업체 상세 정보 조회
- const vendorsData = await selectVendors(tx, {
- where: inArray(vendors.id, paginatedIds),
- orderBy: input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName)
- )
- : [asc(vendors.createdAt)],
- });
-
- // 3) 각 벤더별 PQ 상태 정보 추가
- const vendorsWithPqInfo = await Promise.all(
- vendorsData.map(async (vendor) => {
- // 3-A) 첨부 파일 조회
- const attachments = await tx
- .select({
- id: vendorAttachments.id,
- fileName: vendorAttachments.fileName,
- filePath: vendorAttachments.filePath,
- })
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendor.id));
-
- // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지)
- const generalPqAnswers = await tx
- .select({ count: count() })
- .from(vendorPqCriteriaAnswers)
- .where(
- and(
- eq(vendorPqCriteriaAnswers.vendorId, vendor.id),
- isNull(vendorPqCriteriaAnswers.projectId)
- )
- );
-
- const hasGeneralPq = generalPqAnswers[0]?.count > 0;
-
- // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함)
- const projectPqs = await tx
- .select({
- projectId: vendorProjectPQs.projectId,
- projectName: projects.name,
- status: vendorProjectPQs.status,
- submittedAt: vendorProjectPQs.submittedAt,
- approvedAt: vendorProjectPQs.approvedAt,
- rejectedAt: vendorProjectPQs.rejectedAt
- })
- .from(vendorProjectPQs)
- .innerJoin(
- projects,
- eq(vendorProjectPQs.projectId, projects.id)
- )
- .where(
- and(
- eq(vendorProjectPQs.vendorId, vendor.id),
- not(eq(vendorProjectPQs.status, "REQUESTED")) // REQUESTED 상태는 제외
- )
- );
-
- const hasProjectPq = projectPqs.length > 0;
-
- // 프로젝트 PQ 상태별 카운트
- const projectPqStatusCounts = {
- inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length,
- submitted: projectPqs.filter(p => p.status === "SUBMITTED").length,
- approved: projectPqs.filter(p => p.status === "APPROVED").length,
- rejected: projectPqs.filter(p => p.status === "REJECTED").length,
- total: projectPqs.length
- };
-
- // 3-D) PQ 상태 정보 추가
- return {
- ...vendor,
- hasAttachments: attachments.length > 0,
- attachmentsList: attachments,
- pqInfo: {
- hasGeneralPq,
- hasProjectPq,
- projectPqs,
- projectPqStatusCounts,
- // 현재 PQ 상태 (UI에 표시 용도)
- pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts)
- }
- };
- })
- );
-
- return { data: vendorsWithPqInfo, total };
- });
-
- // 페이지 수
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- console.error("Error in getVendorsInPQ:", err);
- // 에러 발생 시
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)], // 캐싱 키
- {
- revalidate: 3600,
- tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화
- }
- )();
-}
-
-// PQ 상태 표시 함수
-function getPqStatusDisplay(
- vendorStatus: string,
- hasGeneralPq: boolean,
- hasProjectPq: boolean,
- projectPqCounts: { inProgress: number, submitted: number, approved: number, rejected: number, total: number }
-): string {
- // 프로젝트 PQ 상태 문자열 생성
- let projectPqStatus = "";
- if (hasProjectPq) {
- const parts = [];
- if (projectPqCounts.inProgress > 0) {
- parts.push(`진행중: ${projectPqCounts.inProgress}`);
- }
- if (projectPqCounts.submitted > 0) {
- parts.push(`제출: ${projectPqCounts.submitted}`);
- }
- if (projectPqCounts.approved > 0) {
- parts.push(`승인: ${projectPqCounts.approved}`);
- }
- if (projectPqCounts.rejected > 0) {
- parts.push(`거부: ${projectPqCounts.rejected}`);
- }
- projectPqStatus = parts.join(", ");
- }
-
- // 일반 PQ + 프로젝트 PQ 조합 상태
- if (hasGeneralPq && hasProjectPq) {
- return `일반 PQ (${getPqVendorStatusText(vendorStatus)}) + 프로젝트 PQ (${projectPqStatus})`;
- } else if (hasGeneralPq) {
- return `일반 PQ (${getPqVendorStatusText(vendorStatus)})`;
- } else if (hasProjectPq) {
- return `프로젝트 PQ (${projectPqStatus})`;
- }
-
- return "PQ 정보 없음";
-}
-
-// 협력업체 상태 텍스트 변환
-function getPqVendorStatusText(status: string): string {
- switch (status) {
- case "IN_PQ": return "진행중";
- case "PQ_SUBMITTED": return "제출됨";
- case "PQ_FAILED": return "실패";
- case "PQ_APPROVED":
- case "APPROVED": return "승인됨";
- case "READY_TO_SEND": return "거래 준비";
- case "ACTIVE": return "활성";
- case "INACTIVE": return "비활성";
- case "BLACKLISTED": return "거래금지";
- default: return status;
- }
-}
-
-
-export type VendorStatus =
- | "PENDING_REVIEW"
- | "IN_REVIEW"
- | "REJECTED"
- | "IN_PQ"
- | "PQ_SUBMITTED"
- | "PQ_FAILED"
- | "APPROVED"
- | "ACTIVE"
- | "INACTIVE"
- | "BLACKLISTED"
- | "PQ_APPROVED"
-
- export async function updateVendorStatusAction(
- vendorId: number,
- newStatus: VendorStatus
- ) {
- try {
- // 1) Update DB
- await db.update(vendors)
- .set({ status: newStatus })
- .where(eq(vendors.id, vendorId))
-
- // 2) Load vendor’s email & name
- const vendor = await db.select().from(vendors).where(eq(vendors.id, vendorId)).then(r => r[0])
- if (!vendor) {
- return { ok: false, error: "Vendor not found" }
- }
- const headersList = await headers();
- const host = headersList.get('host') || 'localhost:3000';
- const loginUrl = `http://${host}/partners/pq`
-
- // 3) Send email
- await sendEmail({
- to: vendor.email || "",
- subject: `Your PQ Status is now ${newStatus}`,
- template: "vendor-pq-status", // matches .hbs file
- context: {
- name: vendor.vendorName,
- status: newStatus,
- loginUrl: loginUrl, // etc.
- },
- })
- revalidateTag("vendors")
- revalidateTag("vendors-in-pq")
- return { ok: true }
- } catch (error) {
- console.error("updateVendorStatusAction error:", error)
- return { ok: false, error: String(error) }
- }
- }
-
- type ProjectPQStatus = "REQUESTED" | "IN_PROGRESS" | "SUBMITTED" | "APPROVED" | "REJECTED";
-
-/**
- * Update the status of a project-specific PQ for a vendor
- */
-export async function updateProjectPQStatusAction({
- vendorId,
- projectId,
- status,
- comment
-}: {
- vendorId: number;
- projectId: number;
- status: ProjectPQStatus;
- comment?: string;
-}) {
- try {
- const currentDate = new Date();
-
- // 1) Prepare update data with appropriate timestamps
- const updateData: any = {
- status,
- updatedAt: currentDate,
- };
-
- // Add status-specific fields
- if (status === "APPROVED") {
- updateData.approvedAt = currentDate;
- } else if (status === "REJECTED") {
- updateData.rejectedAt = currentDate;
- updateData.rejectReason = comment || null;
- } else if (status === "SUBMITTED") {
- updateData.submittedAt = currentDate;
- }
-
- // 2) Update the project PQ record
- await db
- .update(vendorProjectPQs)
- .set(updateData)
- .where(
- and(
- eq(vendorProjectPQs.vendorId, vendorId),
- eq(vendorProjectPQs.projectId, projectId)
- )
- );
-
- // 3) Load vendor and project details for email
- const vendor = await db
- .select({
- id: vendors.id,
- email: vendors.email,
- vendorName: vendors.vendorName
- })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .then(rows => rows[0]);
-
- if (!vendor) {
- return { ok: false, error: "Vendor not found" };
- }
-
- const project = await db
- .select({
- name: projects.name
- })
- .from(projects)
- .where(eq(projects.id, projectId))
- .then(rows => rows[0]);
-
- if (!project) {
- return { ok: false, error: "Project not found" };
- }
-
- // 4) Send email notification
- await sendEmail({
- to: vendor.email || "",
- subject: `Your Project PQ for ${project.name} is now ${status}`,
- template: "vendor-project-pq-status", // matches .hbs file (you might need to create this)
- context: {
- name: vendor.vendorName,
- status,
- projectName: project.name,
- rejectionReason: status === "REJECTED" ? comment : undefined,
- hasRejectionReason: status === "REJECTED" && !!comment,
- loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`,
- approvalDate: status === "APPROVED" ? currentDate.toLocaleDateString() : undefined,
- rejectionDate: status === "REJECTED" ? currentDate.toLocaleDateString() : undefined,
- },
- });
-
- // 5) Revalidate cache tags
- revalidateTag("vendors");
- revalidateTag("vendors-in-pq");
- revalidateTag(`vendor-project-pqs-${vendorId}`);
- revalidateTag(`project-pq-${projectId}`);
- revalidateTag(`project-vendors-${projectId}`);
-
- return { ok: true };
- } catch (error) {
- console.error("updateProjectPQStatusAction error:", error);
- return { ok: false, error: String(error) };
- }
-}
-
-// 코멘트 타입 정의
-interface ItemComment {
- answerId: number;
- checkPoint: string; // 체크포인트 정보 추가
- code: string; // 코드 정보 추가
- comment: string;
-}
-
-/**
- * PQ 변경 요청 처리 서버 액션
- *
- * @param vendorId 협력업체 ID
- * @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성)
- * @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항)
- */
-export async function requestPqChangesAction({
- vendorId,
- projectId,
- comment,
- generalComment,
- reviewerName,
- reviewerId
-}: {
- vendorId: number;
- projectId?: number; // Optional project ID for project-specific PQs
- comment: ItemComment[];
- generalComment?: string;
- reviewerName?: string;
- reviewerId?: string;
-}) {
- try {
- // 1) 상태 업데이트 (PQ 타입에 따라 다르게 처리)
- if (projectId) {
- // 프로젝트 PQ인 경우 vendorProjectPQs 테이블 업데이트
- const projectPq = await db
- .select()
- .from(vendorProjectPQs)
- .where(
- and(
- eq(vendorProjectPQs.vendorId, vendorId),
- eq(vendorProjectPQs.projectId, projectId)
- )
- )
- .then(rows => rows[0]);
-
- if (!projectPq) {
- return { ok: false, error: "Project PQ record not found" };
- }
-
- await db
- .update(vendorProjectPQs)
- .set({
- status: "IN_PROGRESS", // 변경 요청 상태로 설정
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(vendorProjectPQs.vendorId, vendorId),
- eq(vendorProjectPQs.projectId, projectId)
- )
- );
- } else {
- // 일반 PQ인 경우 vendors 테이블 업데이트
- await db
- .update(vendors)
- .set({
- status: "IN_PQ", // 변경 요청 상태로 설정
- updatedAt: new Date(),
- })
- .where(eq(vendors.id, vendorId));
- }
-
- // 2) 협력업체 정보 가져오기
- const vendor = await db
- .select()
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .then(r => r[0]);
-
- if (!vendor) {
- return { ok: false, error: "Vendor not found" };
- }
-
- // 프로젝트 정보 가져오기 (프로젝트 PQ인 경우)
- let projectName = "";
- if (projectId) {
- const project = await db
- .select({
- name: projects.name
- })
- .from(projects)
- .where(eq(projects.id, projectId))
- .then(rows => rows[0]);
-
- projectName = project?.name || "Unknown Project";
- }
-
- // 3) 각 항목별 코멘트 저장
- const currentDate = new Date();
-
-
- // 병렬로 모든 코멘트 저장
- if (comment && comment.length > 0) {
- const insertPromises = comment.map(item =>
- db.insert(vendorPqReviewLogs)
- .values({
- vendorPqCriteriaAnswerId: item.answerId,
- // reviewerId: reviewerId,
- reviewerName: reviewerName,
- reviewerComment: item.comment,
- createdAt: currentDate,
- // 추가 메타데이터 필드가 있다면 저장
- // 이런 메타데이터는 DB 스키마에 해당 필드가 있어야 함
- // meta: JSON.stringify({ checkPoint: item.checkPoint, code: item.code })
- })
- );
-
- // 모든 삽입 기다리기
- await Promise.all(insertPromises);
- }
-
- // 4) 변경 요청 이메일 보내기
- // 코멘트 목록 준비
- const commentItems = comment.map(item => ({
- id: item.answerId,
- code: item.code,
- checkPoint: item.checkPoint,
- text: item.comment
- }));
-
- // PQ 유형에 따라 이메일 제목 및 내용 조정
- const emailSubject = projectId
- ? `[IMPORTANT] Your Project PQ (${projectName}) requires changes`
- : `[IMPORTANT] Your PQ submission requires changes`;
-
- // 로그인 URL - 프로젝트 PQ인 경우 다른 경로로 안내
- const loginUrl = projectId
- ? `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`
- : `${process.env.NEXT_PUBLIC_URL}/partners/pq`;
-
- await sendEmail({
- to: vendor.email || "",
- subject: emailSubject,
- template: "vendor-pq-comment", // matches .hbs file
- context: {
- name: vendor.vendorName,
- vendorCode: vendor.vendorCode,
- loginUrl,
- comments: commentItems,
- generalComment: generalComment || "",
- hasGeneralComment: !!generalComment,
- commentCount: commentItems.length,
- projectId,
- projectName,
- isProjPQ: !!projectId,
- },
- });
-
- // 5) 캐시 무효화 - PQ 유형에 따라 적절한 태그 무효화
- revalidateTag("vendors");
- revalidateTag("vendors-in-pq");
-
- if (projectId) {
- revalidateTag(`vendor-project-pqs-${vendorId}`);
- revalidateTag(`project-pq-${projectId}`);
- revalidateTag(`project-vendors-${projectId}`);
- }
-
- return { ok: true };
- } catch (error) {
- console.error("requestPqChangesAction error:", error);
- return { ok: false, error: String(error) };
- }
-}
-
-interface AddReviewCommentInput {
- answerId: number // vendorPqCriteriaAnswers.id
- comment: string
- reviewerName?: string
-}
-
-export async function addReviewCommentAction(input: AddReviewCommentInput) {
- try {
- // 1) Check that the answer row actually exists
- const existing = await db
- .select({ id: vendorPqCriteriaAnswers.id })
- .from(vendorPqCriteriaAnswers)
- .where(eq(vendorPqCriteriaAnswers.id, input.answerId))
-
- if (existing.length === 0) {
- return { ok: false, error: "Item not found" }
- }
-
- // 2) Insert the log
- await db.insert(vendorPqReviewLogs).values({
- vendorPqCriteriaAnswerId: input.answerId,
- reviewerComment: input.comment,
- reviewerName: input.reviewerName ?? "AdminUser",
- })
-
- return { ok: true }
- } catch (error) {
- console.error("addReviewCommentAction error:", error)
- return { ok: false, error: String(error) }
- }
-}
-
-interface GetItemReviewLogsInput {
- answerId: number
-}
-
-export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) {
- try {
-
- const logs = await db
- .select()
- .from(vendorPqReviewLogs)
- .where(eq(vendorPqReviewLogs.vendorPqCriteriaAnswerId, input.answerId))
- .orderBy(desc(vendorPqReviewLogs.createdAt));
-
- return { ok: true, data: logs };
- } catch (error) {
- console.error("getItemReviewLogsAction error:", error);
- return { ok: false, error: String(error) };
- }
-}
-
-export interface VendorPQListItem {
- projectId: number;
- projectName: string;
- status: string;
- submittedAt?: Date | null; // Change to accept both undefined and null
-}
-
-export interface VendorPQsList {
- hasGeneralPq: boolean;
- generalPqStatus?: string; // vendor.status for general PQ
- projectPQs: VendorPQListItem[];
-}
-
-export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList> {
- try {
- // 1. Check if vendor has general PQ answers
- const generalPqAnswers = await db
- .select({ count: count() })
- .from(vendorPqCriteriaAnswers)
- .where(
- and(
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- isNull(vendorPqCriteriaAnswers.projectId)
- )
- );
-
- const hasGeneralPq = (generalPqAnswers[0]?.count || 0) > 0;
-
- // 2. Get vendor status for general PQ
- let generalPqStatus;
- if (hasGeneralPq) {
- const vendor = await db
- .select({ status: vendors.status })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .then(rows => rows[0]);
-
- generalPqStatus = vendor?.status;
- }
-
- // 3. Get project PQs
- const projectPQs = await db
- .select({
- projectId: vendorProjectPQs.projectId,
- projectName: projects.name,
- status: vendorProjectPQs.status,
- submittedAt: vendorProjectPQs.submittedAt
- })
- .from(vendorProjectPQs)
- .innerJoin(
- projects,
- eq(vendorProjectPQs.projectId, projects.id)
- )
- .where(
- and(
- eq(vendorProjectPQs.vendorId, vendorId),
- not(eq(vendorProjectPQs.status, "REQUESTED")) // Exclude requests that haven't been started
- )
- )
- .orderBy(vendorProjectPQs.updatedAt);
-
- return {
- hasGeneralPq,
- generalPqStatus,
- projectPQs: projectPQs
- };
-
- } catch (error) {
- console.error("Error fetching vendor PQs list:", error);
- return {
- hasGeneralPq: false,
- projectPQs: []
- };
- }
-}
-
-
-export async function loadGeneralPQData(vendorId: number) {
- "use server";
- return getPQDataByVendorId(vendorId)
-}
-
-export async function loadProjectPQData(vendorId: number, projectId: number) {
- "use server";
- return getPQDataByVendorId(vendorId, projectId)
-}
-
-export async function loadGeneralPQAction(vendorId: number) {
- return getPQDataByVendorId(vendorId);
-}
-
-export async function loadProjectPQAction(vendorId: number, projectId?: number): Promise<PQGroupData[]> {
- if (!projectId) {
- throw new Error("Project ID is required for loading project PQ data");
- }
- return getPQDataByVendorId(vendorId, projectId);
-}
-
-
-
-export async function getAllPQsByVendorId(vendorId: number) {
- try {
- const pqList = await db
- .select({
- id: vendorPQSubmissions.id,
- type: vendorPQSubmissions.type,
- status: vendorPQSubmissions.status,
- projectId: vendorPQSubmissions.projectId,
- projectName: projects.name,
- createdAt: vendorPQSubmissions.createdAt,
- updatedAt: vendorPQSubmissions.updatedAt,
- submittedAt: vendorPQSubmissions.submittedAt,
- approvedAt: vendorPQSubmissions.approvedAt,
- rejectedAt: vendorPQSubmissions.rejectedAt,
- rejectReason: vendorPQSubmissions.rejectReason,
- })
- .from(vendorPQSubmissions)
- .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
- .where(eq(vendorPQSubmissions.vendorId, vendorId))
- .orderBy(desc(vendorPQSubmissions.createdAt));
-
- return pqList;
- } catch (error) {
- console.error("Error fetching PQ list:", error);
- return [];
- }
-}
-
-// 특정 PQ의 상세 정보 조회 (개별 PQ 페이지용)
-export async function getPQById(pqSubmissionId: number, vendorId: number) {
- try {
- const pq = await db
- .select({
- id: vendorPQSubmissions.id,
- vendorId: vendorPQSubmissions.vendorId,
- projectId: vendorPQSubmissions.projectId,
- type: vendorPQSubmissions.type,
- status: vendorPQSubmissions.status,
- createdAt: vendorPQSubmissions.createdAt,
- submittedAt: vendorPQSubmissions.submittedAt,
- approvedAt: vendorPQSubmissions.approvedAt,
- rejectedAt: vendorPQSubmissions.rejectedAt,
- rejectReason: vendorPQSubmissions.rejectReason,
-
- // 벤더 정보 (추가)
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- vendorStatus: vendors.status,
-
- // 프로젝트 정보 (조인)
- projectName: projects.name,
- projectCode: projects.code,
- })
- .from(vendorPQSubmissions)
- .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
- .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
- .where(
- and(
- eq(vendorPQSubmissions.id, pqSubmissionId),
- eq(vendorPQSubmissions.vendorId, vendorId)
- )
- )
- .limit(1)
- .then(rows => rows[0]);
-
- if (!pq) {
- throw new Error("PQ not found or access denied");
- }
-
- return pq;
- } catch (error) {
- console.error("Error fetching PQ by ID:", error);
- throw error;
- }
-}
-
-export async function getPQStatusCounts(vendorId: number) {
- try {
- // 모든 PQ 상태 조회 (일반 PQ + 프로젝트 PQ)
- const pqStatuses = await db
- .select({
- status: vendorPQSubmissions.status,
- count: count(),
- })
- .from(vendorPQSubmissions)
- .where(eq(vendorPQSubmissions.vendorId, vendorId))
- .groupBy(vendorPQSubmissions.status);
-
- // 상태별 개수를 객체로 변환
- const statusCounts = {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- };
-
- // 조회된 결과를 statusCounts 객체에 매핑
- pqStatuses.forEach((item) => {
- if (item.status in statusCounts) {
- statusCounts[item.status as keyof typeof statusCounts] = item.count;
- }
- });
-
- return statusCounts;
- } catch (error) {
- console.error("Error fetching PQ status counts:", error);
- return {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- };
- }
-}
-
-// 상태 레이블 함수
-function getStatusLabel(status: string): string {
- switch (status) {
- case "REQUESTED":
- return "요청됨";
- case "IN_PROGRESS":
- return "진행 중";
- case "SUBMITTED":
- return "제출됨";
- case "APPROVED":
- return "승인됨";
- case "REJECTED":
- return "거부됨";
- default:
- return status;
- }
-}
-
-export async function getPQSubmissions(input: GetPQSubmissionsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- const pqFilterMapping = createPQFilterMapping();
- const joinedTables = getPQJoinedTables();
-
- console.log(input, "input")
-
- // 1) 고급 필터 조건 (DataTableAdvancedToolbar에서)
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: vendorPQSubmissions,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- joinedTables,
- customColumnMapping: pqFilterMapping,
- });
- console.log("advancedWhere:", advancedWhere);
- }
-
- // 2) 기본 필터 조건 (PQFilterSheet에서)
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: vendorPQSubmissions,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- joinedTables,
- customColumnMapping: pqFilterMapping,
- });
- console.log("basicWhere:", basicWhere);
- }
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- // 기존 검색 조건들
- const nameCondition = ilike(vendors.vendorName, s);
- if (nameCondition) validSearchConditions.push(nameCondition);
-
- const codeCondition = ilike(vendors.vendorCode, s);
- if (codeCondition) validSearchConditions.push(codeCondition);
-
- const projectNameCondition = ilike(projects.name, s);
- if (projectNameCondition) validSearchConditions.push(projectNameCondition);
-
- const projectCodeCondition = ilike(projects.code, s);
- if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
-
- // 새로 추가된 검색 조건들
- const pqNumberCondition = ilike(vendorPQSubmissions.pqNumber, s);
- if (pqNumberCondition) validSearchConditions.push(pqNumberCondition);
-
- const requesterCondition = ilike(users.name, s);
- if (requesterCondition) validSearchConditions.push(requesterCondition);
-
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
-
- // 4) 날짜 조건
- let fromDateWhere: SQL<unknown> | undefined = undefined;
- let toDateWhere: SQL<unknown> | undefined = undefined;
-
- if (input.submittedDateFrom) {
- const fromDate = new Date(input.submittedDateFrom);
- const condition = gte(vendorPQSubmissions.submittedAt, fromDate);
- if (condition) fromDateWhere = condition;
- }
-
- if (input.submittedDateTo) {
- const toDate = new Date(input.submittedDateTo);
- const condition = lte(vendorPQSubmissions.submittedAt, toDate);
- if (condition) toDateWhere = condition;
- }
-
- // 5) 최종 WHERE 조건 생성 - 각 그룹을 AND로 연결
- const whereConditions: SQL<unknown>[] = [];
-
- // 고급 필터 조건 추가
- if (advancedWhere) whereConditions.push(advancedWhere);
-
- // 기본 필터 조건 추가
- if (basicWhere) whereConditions.push(basicWhere);
-
- // 기타 조건들 추가
- if (globalWhere) whereConditions.push(globalWhere);
- if (fromDateWhere) whereConditions.push(fromDateWhere);
- if (toDateWhere) whereConditions.push(toDateWhere);
-
- // 모든 조건을 AND로 연결
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- console.log("Final WHERE conditions:", {
- advancedWhere: !!advancedWhere,
- basicWhere: !!basicWhere,
- globalWhere: !!globalWhere,
- dateConditions: !!(fromDateWhere || toDateWhere),
- totalConditions: whereConditions.length
- });
-
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(vendorPQSubmissions)
- .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
- .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
- .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id))
- .leftJoin(vendorInvestigations, eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id))
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0 };
- }
-
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof vendorPQSubmissions.$inferSelect;
- return sort.desc ? desc(vendorPQSubmissions[column]) : asc(vendorPQSubmissions[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(vendorPQSubmissions.updatedAt));
- }
-
- const pqSubmissions = await db
- .select({
- id: vendorPQSubmissions.id,
- type: vendorPQSubmissions.type,
- pqNumber: vendorPQSubmissions.pqNumber,
- requesterId: vendorPQSubmissions.requesterId,
- requesterName: users.name,
- status: vendorPQSubmissions.status,
- createdAt: vendorPQSubmissions.createdAt,
- updatedAt: vendorPQSubmissions.updatedAt,
- submittedAt: vendorPQSubmissions.submittedAt,
- approvedAt: vendorPQSubmissions.approvedAt,
- rejectedAt: vendorPQSubmissions.rejectedAt,
- rejectReason: vendorPQSubmissions.rejectReason,
- // Vendor 정보
- vendorId: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- taxId: vendors.taxId,
- vendorStatus: vendors.status,
- // Project 정보 (프로젝트 PQ인 경우)
- projectId: projects.id,
- projectName: projects.name,
- projectCode: projects.code,
- })
- .from(vendorPQSubmissions)
- .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
- .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
- .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id))
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- // 8) 각 PQ 제출에 대한 추가 정보 조회 (기존과 동일)
- const pqSubmissionsWithDetails = await Promise.all(
- pqSubmissions.map(async (submission) => {
- // 기본 반환 객체
- const baseResult = {
- ...submission,
- answerCount: 0,
- attachmentCount: 0,
- pqStatus: getStatusLabel(submission.status),
- pqTypeLabel: submission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ",
- };
-
- // vendorId가 null이면 기본 정보만 반환
- if (submission.vendorId === null) {
- return baseResult;
- }
-
- try {
- // 답변 수 조회
- const vendorId = submission.vendorId;
-
- const answerWhereConditions: SQL<unknown>[] = [];
-
- const vendorCondition = eq(vendorPqCriteriaAnswers.vendorId, vendorId);
- if (vendorCondition) answerWhereConditions.push(vendorCondition);
-
- let projectCondition: SQL<unknown> | undefined;
- if (submission.projectId !== null) {
- projectCondition = eq(vendorPqCriteriaAnswers.projectId, submission.projectId);
- } else {
- projectCondition = isNull(vendorPqCriteriaAnswers.projectId);
- }
-
- if (projectCondition) answerWhereConditions.push(projectCondition);
-
- const answerWhere = and(...answerWhereConditions);
-
- const answersResult = await db
- .select({ count: count() })
- .from(vendorPqCriteriaAnswers)
- .where(answerWhere);
-
- const answerCount = answersResult[0]?.count || 0;
-
- // 첨부 파일 수 조회
- const attachmentsResult = await db
- .select({ count: count() })
- .from(vendorPqCriteriaAnswers)
- .leftJoin(
- vendorCriteriaAttachments,
- eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, vendorPqCriteriaAnswers.id)
- )
- .where(answerWhere);
-
- const attachmentCount = attachmentsResult[0]?.count || 0;
-
- const requesters = alias(users, 'requesters');
- const qmManagers = alias(users, 'qmManagers');
-
- const investigationResult = await db
- .select({
- id: vendorInvestigations.id,
- investigationStatus: vendorInvestigations.investigationStatus,
- evaluationType: vendorInvestigations.evaluationType,
- investigationAddress: vendorInvestigations.investigationAddress,
- investigationMethod: vendorInvestigations.investigationMethod,
- scheduledStartAt: vendorInvestigations.scheduledStartAt,
- scheduledEndAt: vendorInvestigations.scheduledEndAt,
- requestedAt: vendorInvestigations.requestedAt,
- confirmedAt: vendorInvestigations.confirmedAt,
- completedAt: vendorInvestigations.completedAt,
- forecastedAt: vendorInvestigations.forecastedAt,
- evaluationScore: vendorInvestigations.evaluationScore,
- evaluationResult: vendorInvestigations.evaluationResult,
- investigationNotes: vendorInvestigations.investigationNotes,
- requesterId: vendorInvestigations.requesterId,
- requesterName: requesters.name,
- qmManagerId: vendorInvestigations.qmManagerId,
- qmManagerName: qmManagers.name,
- qmManagerEmail: qmManagers.email,
- })
- .from(vendorInvestigations)
- .leftJoin(requesters, eq(vendorInvestigations.requesterId, requesters.id))
- .leftJoin(qmManagers, eq(vendorInvestigations.qmManagerId, qmManagers.id))
- .where(and(
- eq(vendorInvestigations.vendorId, submission.vendorId),
- eq(vendorInvestigations.pqSubmissionId, submission.id)
- ))
- .orderBy(desc(vendorInvestigations.createdAt))
- .limit(1);
-
- const investigation = investigationResult[0] || null;
-
- return {
- ...baseResult,
- answerCount,
- attachmentCount,
- investigation
- };
- } catch (error) {
- console.error("Error fetching PQ details:", error);
- return baseResult;
- }
- })
- );
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: pqSubmissionsWithDetails, pageCount };
- } catch (err) {
- console.error("Error in getPQSubmissions:", err);
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)], // 캐싱 키
- {
- revalidate: 3600,
- tags: ["pq-submissions"], // revalidateTag 호출 시 무효화
- }
- )();
-}
-
-export async function getPQStatusCountsAll() {
- try {
- // 모든 PQ 상태별 개수 조회 (벤더 제한 없음)
- const pqStatuses = await db
- .select({
- status: vendorPQSubmissions.status,
- count: count(),
- })
- .from(vendorPQSubmissions)
- .groupBy(vendorPQSubmissions.status);
-
- // 상태별 개수를 객체로 변환
- const statusCounts = {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- };
-
- // 조회된 결과를 statusCounts 객체에 매핑
- pqStatuses.forEach((item) => {
- if (item.status in statusCounts) {
- statusCounts[item.status as keyof typeof statusCounts] = item.count;
- }
- });
-
- return statusCounts;
- } catch (error) {
- console.error("Error fetching PQ status counts:", error);
- return {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- };
- }
-}
-
-// PQ 타입별, 상태별 개수 집계 함수 (추가 옵션)
-export async function getPQDetailedStatusCounts() {
- try {
- // 타입별, 상태별 개수 조회
- const pqStatuses = await db
- .select({
- type: vendorPQSubmissions.type,
- status: vendorPQSubmissions.status,
- count: count(),
- })
- .from(vendorPQSubmissions)
- .groupBy(vendorPQSubmissions.type, vendorPQSubmissions.status);
-
- // 결과를 저장할 객체 초기화
- const result = {
- GENERAL: {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- total: 0
- },
- PROJECT: {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- total: 0
- },
- total: {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- total: 0
- }
- };
-
- // 결과 매핑
- pqStatuses.forEach((item) => {
- if (item.type && item.status) {
- const type = item.type as keyof typeof result;
- const status = item.status as keyof typeof result.GENERAL;
-
- if (type in result && status in result[type]) {
- // 타입별 상태 카운트 업데이트
- result[type][status] = item.count;
-
- // 타입별 합계 업데이트
- result[type].total += item.count;
-
- // 전체 상태별 카운트 업데이트
- result.total[status] += item.count;
-
- // 전체 합계 업데이트
- result.total.total += item.count;
- }
- }
- });
-
- return result;
- } catch (error) {
- console.error("Error fetching detailed PQ status counts:", error);
- return {
- GENERAL: {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- total: 0
- },
- PROJECT: {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- total: 0
- },
- total: {
- REQUESTED: 0,
- IN_PROGRESS: 0,
- SUBMITTED: 0,
- APPROVED: 0,
- REJECTED: 0,
- total: 0
- }
- };
- }
-}
-
-// PQ 승인 액션
-export async function approvePQAction({
- pqSubmissionId,
- vendorId,
-}: {
- pqSubmissionId: number;
- vendorId: number;
-}) {
- unstable_noStore();
-
- try {
- const headersList = await headers();
- const host = headersList.get('host') || 'localhost:3000';
- const currentDate = new Date();
-
- // 1. PQ 제출 정보 조회
- const pqSubmission = await db
- .select({
- id: vendorPQSubmissions.id,
- vendorId: vendorPQSubmissions.vendorId,
- projectId: vendorPQSubmissions.projectId,
- type: vendorPQSubmissions.type,
- status: vendorPQSubmissions.status,
- })
- .from(vendorPQSubmissions)
- .where(
- and(
- eq(vendorPQSubmissions.id, pqSubmissionId),
- eq(vendorPQSubmissions.vendorId, vendorId)
- )
- )
- .then(rows => rows[0]);
-
- if (!pqSubmission) {
- return { ok: false, error: "PQ submission not found" };
- }
-
- // 2. 상태 확인 (SUBMITTED 상태만 승인 가능)
- if (pqSubmission.status !== "SUBMITTED") {
- return {
- ok: false,
- error: `Cannot approve PQ in current status: ${pqSubmission.status}`
- };
- }
-
- // 3. 벤더 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- status: vendors.status,
- })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .then(rows => rows[0]);
-
- if (!vendor) {
- return { ok: false, error: "Vendor not found" };
- }
-
- // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
- let projectName = '';
- if (pqSubmission.projectId) {
- const projectData = await db
- .select({
- id: projects.id,
- name: projects.name,
- })
- .from(projects)
- .where(eq(projects.id, pqSubmission.projectId))
- .then(rows => rows[0]);
-
- projectName = projectData?.name || 'Unknown Project';
- }
-
- // 5. PQ 상태 업데이트
- await db
- .update(vendorPQSubmissions)
- .set({
- status: "APPROVED",
- approvedAt: currentDate,
- updatedAt: currentDate,
- })
- .where(eq(vendorPQSubmissions.id, pqSubmissionId));
-
- // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
- if (pqSubmission.type === "GENERAL") {
- await db
- .update(vendors)
- .set({
- status: "PQ_APPROVED",
- updatedAt: currentDate,
- })
- .where(eq(vendors.id, vendorId));
- }
-
- // 7. 벤더에게 이메일 알림 발송
- if (vendor.email) {
- try {
- const emailSubject = pqSubmission.projectId
- ? `[eVCP] Project PQ Approved for ${projectName}`
- : "[eVCP] General PQ Approved";
-
- const portalUrl = `${host}/partners/pq`;
-
- await sendEmail({
- to: vendor.email,
- subject: emailSubject,
- template: "pq-approved-vendor",
- context: {
- vendorName: vendor.vendorName,
- projectId: pqSubmission.projectId,
- projectName: projectName,
- isProjectPQ: !!pqSubmission.projectId,
- approvedDate: currentDate.toLocaleString(),
- portalUrl,
- }
- });
- } catch (emailError) {
- console.error("Failed to send vendor notification:", emailError);
- // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
- }
- }
-
- // 8. 캐시 무효화
- revalidateTag("vendors");
- revalidateTag("vendor-status-counts");
- revalidateTag("pq-submissions");
- revalidateTag(`vendor-pq-submissions-${vendorId}`);
-
- if (pqSubmission.projectId) {
- revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
- revalidateTag(`project-vendors-${pqSubmission.projectId}`);
- }
-
- return { ok: true };
- } catch (error) {
- console.error("PQ approve error:", error);
- return { ok: false, error: getErrorMessage(error) };
- }
-}
-
-// PQ 거부 액션
-export async function rejectPQAction({
- pqSubmissionId,
- vendorId,
- rejectReason
-}: {
- pqSubmissionId: number;
- vendorId: number;
- rejectReason: string;
-}) {
- unstable_noStore();
-
- try {
- const headersList = await headers();
- const host = headersList.get('host') || 'localhost:3000';
- const currentDate = new Date();
-
- // 1. PQ 제출 정보 조회
- const pqSubmission = await db
- .select({
- id: vendorPQSubmissions.id,
- vendorId: vendorPQSubmissions.vendorId,
- projectId: vendorPQSubmissions.projectId,
- type: vendorPQSubmissions.type,
- status: vendorPQSubmissions.status,
- })
- .from(vendorPQSubmissions)
- .where(
- and(
- eq(vendorPQSubmissions.id, pqSubmissionId),
- eq(vendorPQSubmissions.vendorId, vendorId)
- )
- )
- .then(rows => rows[0]);
-
- if (!pqSubmission) {
- return { ok: false, error: "PQ submission not found" };
- }
-
- // 2. 상태 확인 (SUBMITTED 상태만 거부 가능)
- if (pqSubmission.status !== "SUBMITTED") {
- return {
- ok: false,
- error: `Cannot reject PQ in current status: ${pqSubmission.status}`
- };
- }
-
- // 3. 벤더 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- status: vendors.status,
- })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .then(rows => rows[0]);
-
- if (!vendor) {
- return { ok: false, error: "Vendor not found" };
- }
-
- // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
- let projectName = '';
- if (pqSubmission.projectId) {
- const projectData = await db
- .select({
- id: projects.id,
- name: projects.name,
- })
- .from(projects)
- .where(eq(projects.id, pqSubmission.projectId))
- .then(rows => rows[0]);
-
- projectName = projectData?.name || 'Unknown Project';
- }
-
- // 5. PQ 상태 업데이트
- await db
- .update(vendorPQSubmissions)
- .set({
- status: "REJECTED",
- rejectedAt: currentDate,
- rejectReason: rejectReason,
- updatedAt: currentDate,
- })
- .where(eq(vendorPQSubmissions.id, pqSubmissionId));
-
- // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
- if (pqSubmission.type === "GENERAL") {
- await db
- .update(vendors)
- .set({
- status: "PQ_FAILED",
- updatedAt: currentDate,
- })
- .where(eq(vendors.id, vendorId));
- }
-
- // 7. 벤더에게 이메일 알림 발송
- if (vendor.email) {
- try {
- const emailSubject = pqSubmission.projectId
- ? `[eVCP] Project PQ Rejected for ${projectName}`
- : "[eVCP] General PQ Rejected";
-
- const portalUrl = `${host}/partners/pq`;
-
- await sendEmail({
- to: vendor.email,
- subject: emailSubject,
- template: "pq-rejected-vendor",
- context: {
- vendorName: vendor.vendorName,
- projectId: pqSubmission.projectId,
- projectName: projectName,
- isProjectPQ: !!pqSubmission.projectId,
- rejectedDate: currentDate.toLocaleString(),
- rejectReason: rejectReason,
- portalUrl,
- }
- });
- } catch (emailError) {
- console.error("Failed to send vendor notification:", emailError);
- // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
- }
- }
-
- // 8. 캐시 무효화
- revalidateTag("vendors");
- revalidateTag("vendor-status-counts");
- revalidateTag("pq-submissions");
- revalidateTag(`vendor-pq-submissions-${vendorId}`);
-
- if (pqSubmission.projectId) {
- revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
- revalidateTag(`project-vendors-${pqSubmission.projectId}`);
- }
-
- return { ok: true };
- } catch (error) {
- console.error("PQ reject error:", error);
- return { ok: false, error: getErrorMessage(error) };
- }
-}
-
-
-// 실사 의뢰 생성 서버 액션
-export async function requestInvestigationAction(
- pqSubmissionIds: number[],
- data: {
- evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT",
- qmManagerId: number,
- forecastedAt: Date,
- investigationAddress: string,
- investigationNotes?: string
- }
-) {
- try {
- // 세션에서 요청자 정보 가져오기
- const session = await getServerSession(authOptions);
- const requesterId = session?.user?.id ? Number(session.user.id) : null;
-
- if (!requesterId) {
- return { success: false, error: "인증된 사용자만 실사를 의뢰할 수 있습니다." };
- }
-
- const result = await db.transaction(async (tx) => {
- // PQ 제출 정보 조회
- const pqSubmissions = await tx
- .select({
- id: vendorPQSubmissions.id,
- vendorId: vendorPQSubmissions.vendorId,
- })
- .from(vendorPQSubmissions)
- .where(
- and(
- inArray(vendorPQSubmissions.id, pqSubmissionIds),
- eq(vendorPQSubmissions.status, "APPROVED")
- )
- );
-
- if (pqSubmissions.length === 0) {
- throw new Error("승인된 PQ 제출 항목이 없습니다.");
- }
-
- const now = new Date();
-
- // 각 PQ에 대한 실사 요청 생성 - 타입이 정확히 맞는지 확인
- const investigations = pqSubmissions.map((pq) => {
- return {
- vendorId: pq.vendorId,
- pqSubmissionId: pq.id,
- investigationStatus: "PLANNED" as const, // enum 타입으로 명시적 지정
- evaluationType: data.evaluationType,
- qmManagerId: data.qmManagerId,
- forecastedAt: data.forecastedAt,
- investigationAddress: data.investigationAddress,
- investigationNotes: data.investigationNotes || null,
- requesterId: requesterId,
- requestedAt: now,
- createdAt: now,
- updatedAt: now,
- };
- });
-
- // 실사 요청 저장
- const created = await tx
- .insert(vendorInvestigations)
- .values(investigations)
- .returning();
-
- return created;
- });
-
- // 캐시 무효화
- revalidateTag("vendor-investigations");
- revalidateTag("pq-submissions");
-
- return {
- success: true,
- count: result.length,
- data: result
- };
- } catch (err) {
- console.error("실사 의뢰 중 오류 발생:", err);
- return {
- success: false,
- error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
- };
- }
-}
-
-// 실사 의뢰 취소 서버 액션
-export async function cancelInvestigationAction(investigationIds: number[]) {
- try {
- const session = await getServerSession(authOptions)
- const userId = session?.user?.id ? Number(session.user.id) : null
-
- if (!userId) {
- return { success: false, error: "인증된 사용자만 실사를 취소할 수 있습니다." }
- }
-
- const result = await db.transaction(async (tx) => {
- // PLANNED 상태인 실사만 취소 가능
- const updatedInvestigations = await tx
- .update(vendorInvestigations)
- .set({
- investigationStatus: "CANCELED",
- updatedAt: new Date(),
- })
- .where(
- and(
- inArray(vendorInvestigations.id, investigationIds),
- eq(vendorInvestigations.investigationStatus, "PLANNED")
- )
- )
- .returning()
-
- return updatedInvestigations
- })
-
- // 캐시 무효화
- revalidateTag("vendor-investigations")
- revalidateTag("pq-submissions")
-
- return {
- success: true,
- count: result.length,
- data: result
- }
- } catch (err) {
- console.error("실사 취소 중 오류 발생:", err)
- return {
- success: false,
- error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
- }
- }
-}
-
-// 실사 결과 발송 서버 액션
-export async function sendInvestigationResultsAction(investigationIds: number[]) {
- try {
- const session = await getServerSession(authOptions)
- const userId = session?.user?.id ? Number(session.user.id) : null
-
- if (!userId) {
- return { success: false, error: "인증된 사용자만 실사 결과를 발송할 수 있습니다." }
- }
-
- // 여기서는 실사 상태를 업데이트하고, 필요하다면 이메일도 발송할 수 있습니다
- // 이메일 발송 로직은 서버 액션 내에서 구현할 수 있습니다
- const result = await db.transaction(async (tx) => {
- // 완료된 실사만 결과 발송 가능
- const investigations = await tx
- .select()
- .from(vendorInvestigations)
- .where(
- and(
- inArray(vendorInvestigations.id, investigationIds),
- eq(vendorInvestigations.investigationStatus, "COMPLETED")
- )
- )
-
- if (investigations.length === 0) {
- throw new Error("발송할 수 있는 완료된 실사가 없습니다.")
- }
-
- // 여기에 이메일 발송 로직 추가
- // 예: await sendInvestigationResultEmails(investigations)
-
- // 필요하다면 상태 업데이트 (예: 결과 발송됨 상태 추가)
- const updatedInvestigations = await tx
- .update(vendorInvestigations)
- .set({
- // 예시: 결과 발송 표시를 위한 필드 업데이트
- // resultSent: true,
- // resultSentAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- inArray(vendorInvestigations.id, investigationIds)
- )
- .returning()
-
- return updatedInvestigations
- })
-
- // 캐시 무효화
- revalidateTag("vendor-investigations")
- revalidateTag("pq-submissions")
-
- return {
- success: true,
- count: result.length,
- data: result
- }
- } catch (err) {
- console.error("실사 결과 발송 중 오류 발생:", err)
- return {
- success: false,
- error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
- }
- }
-}
-
-
-export async function getQMManagers() {
- try {
- // QM 부서 사용자만 필터링 (department 필드가 있다고 가정)
- // 또는 QM 역할을 가진 사용자만 필터링 (role 필드가 있다고 가정)
- const qmUsers = await db
- .select({
- id: users.id,
- name: users.name,
- email: users.email,
- })
- .from(users)
- // .where(
- // // 필요에 따라 조건 조정 (예: QM 부서 또는 특정 역할만)
- // // eq(users.department, "QM") 또는
- // // eq(users.role, "QM_MANAGER")
- // // 테스트를 위해 모든 사용자 반환도 가능
- // eq(users.active, true)
- // )
- .orderBy(users.name)
-
- return {
- data: qmUsers,
- success: true
- }
- } catch (error) {
- console.error("QM 담당자 목록 조회 오류:", error)
- return {
- data: [],
- success: false,
- error: error instanceof Error ? error.message : "QM 담당자 목록을 가져오는 중 오류가 발생했습니다."
- }
- }
-}
-
-export async function getFactoryLocationAnswer(vendorId: number, projectId: number | null = null) {
- try {
- // 1. "Location of Factory" 체크포인트를 가진 criteria 찾기
- const criteria = await db
- .select({
- id: pqCriterias.id
- })
- .from(pqCriterias)
- .where(ilike(pqCriterias.checkPoint, "%Location of Factory%"))
- .limit(1);
-
- if (!criteria.length) {
- return { success: false, message: "Factory Location 질문을 찾을 수 없습니다." };
- }
-
- const criteriaId = criteria[0].id;
-
- // 2. 해당 criteria에 대한 벤더의 응답 조회
- const answerQuery = db
- .select({
- answer: vendorPqCriteriaAnswers.answer
- })
- .from(vendorPqCriteriaAnswers)
- .where(
- and(
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- eq(vendorPqCriteriaAnswers.criteriaId, criteriaId)
- )
- );
-
- // 프로젝트 ID가 있으면 추가 조건
- if (projectId !== null) {
- answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, projectId));
- } else {
- answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, null));
- }
-
- const answers = await answerQuery.limit(1);
-
- if (!answers.length || !answers[0].answer) {
- return { success: false, message: "공장 위치 정보를 찾을 수 없습니다." };
- }
-
- return {
- success: true,
- factoryLocation: answers[0].answer
- };
- } catch (error) {
- console.error("Factory location 조회 오류:", error);
- return { success: false, message: "오류가 발생했습니다." };
- }
+"use server"
+
+import db from "@/db/db"
+import { CopyPqListInput, CreatePqListInput, copyPqListSchema, createPqListSchema, GetPqListsSchema, GetPQSchema, GetPQSubmissionsSchema } from "./validations"
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL, sql, lt, isNotNull} from "drizzle-orm";
+import { z } from "zod"
+import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
+import { format } from "date-fns"
+import { pqCriterias, vendorCriteriaAttachments, vendorInvestigations, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
+import { sendEmail } from "../mail/sendEmail";
+import { decryptWithServerAction } from '@/components/drm/drmUtils'
+
+import { vendorAttachments, vendors } from "@/db/schema/vendors";
+import { saveFile, saveDRMFile } from "@/lib/file-stroage";
+import { GetVendorsSchema } from "../vendors/validations";
+import { selectVendors } from "../vendors/repository";
+import { projects, users } from "@/db/schema";
+import { headers } from 'next/headers';
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { alias } from 'drizzle-orm/pg-core';
+import { createPQFilterMapping, getPQJoinedTables } from "./helper";
+import { pqLists } from "@/db/schema/pq";
+
+export interface PQAttachment {
+ attachId: number
+ fileName: string
+ filePath: string
+ fileSize?: number
+}
+
+export interface PQItem {
+ answerId: number | null
+ criteriaId: number
+ code: string
+ checkPoint: string
+ description: string | null
+ remarks?: string | null
+ // 프로젝트 PQ 전용 필드
+ contractInfo?: string | null
+ additionalRequirement?: string | null
+ answer: string
+ shiComment: string
+ vendorReply: string
+ attachments: PQAttachment[]
+ subGroupName: string
+ inputFormat: string
+
+ createdAt: Date | null
+ updatedAt: Date | null
+}
+
+export interface PQGroupData {
+ groupName: string
+ items: PQItem[]
+}
+
+export interface ProjectPQ {
+ id: number;
+ projectId: number | null;
+ status: string;
+ submittedAt: Date | null;
+ projectCode: string;
+ projectName: string;
+}
+
+export async function getPQProjectsByVendorId(vendorId: number): Promise<ProjectPQ[]> {
+ const result = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ projectId: vendorPQSubmissions.projectId,
+ status: vendorPQSubmissions.status,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(vendorPQSubmissions)
+ .innerJoin(
+ projects,
+ eq(vendorPQSubmissions.projectId, projects.id)
+ )
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(projects.code);
+
+ return result;
+}
+
+export async function getPQDataByVendorId(
+ vendorId: number,
+ projectId?: number
+): Promise<PQGroupData[]> {
+ try {
+ // 기본 쿼리 구성
+ const selectObj = {
+ criteriaId: pqCriterias.id,
+ groupName: pqCriterias.groupName,
+ code: pqCriterias.code,
+ checkPoint: pqCriterias.checkPoint,
+ description: pqCriterias.description,
+ remarks: pqCriterias.remarks,
+
+ // 입력 형식 필드 추가
+ inputFormat: pqCriterias.inputFormat,
+
+ // 협력업체 응답 필드
+ answer: vendorPqCriteriaAnswers.answer,
+ answerId: vendorPqCriteriaAnswers.id,
+
+ // SHI 코멘트와 벤더 답변 필드 추가
+ shiComment: vendorPqCriteriaAnswers.shiComment,
+ vendorReply: vendorPqCriteriaAnswers.vendorReply,
+ createdAt: vendorPqCriteriaAnswers.createdAt,
+ updatedAt: vendorPqCriteriaAnswers.updatedAt,
+
+ // 첨부 파일 필드
+ attachId: vendorCriteriaAttachments.id,
+ fileName: vendorCriteriaAttachments.fileName,
+ filePath: vendorCriteriaAttachments.filePath,
+ fileSize: vendorCriteriaAttachments.fileSize,
+ };
+
+ // Create separate queries for each case instead of modifying the same query variable
+ if (projectId) {
+ // 프로젝트별 PQ 쿼리 - PQ 리스트 기반으로 변경
+ const rows = await db
+ .select(selectObj)
+ .from(pqCriterias)
+ .innerJoin(
+ pqLists,
+ and(
+ eq(pqCriterias.pqListId, pqLists.id),
+ eq(pqLists.projectId, projectId),
+ eq(pqLists.type, "PROJECT"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .leftJoin(
+ vendorPqCriteriaAnswers,
+ and(
+ eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.projectId, projectId)
+ )
+ )
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
+ )
+ .orderBy(pqCriterias.groupName, pqCriterias.code);
+
+ return processQueryResults(rows);
+ } else {
+ // 일반 PQ 쿼리 - PQ 리스트 기반으로 변경
+ const rows = await db
+ .select(selectObj)
+ .from(pqCriterias)
+ .innerJoin(
+ pqLists,
+ and(
+ eq(pqCriterias.pqListId, pqLists.id),
+ eq(pqLists.type, "GENERAL"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .leftJoin(
+ vendorPqCriteriaAnswers,
+ and(
+ eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ )
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
+ )
+ .orderBy(pqCriterias.groupName, pqCriterias.code);
+
+ return processQueryResults(rows);
+ }
+ } catch (error) {
+ console.error("Error fetching PQ data:", error);
+ return [];
+ }
+
+ // Helper function to process query results
+ function processQueryResults(rows: any[]) {
+ // 그룹별로 데이터 구성
+ const groupMap = new Map<string, Record<number, PQItem>>();
+
+ for (const row of rows) {
+ const g = row.groupName || "Others";
+
+ // 그룹 확인
+ if (!groupMap.has(g)) {
+ groupMap.set(g, {});
+ }
+
+ const groupItems = groupMap.get(g)!;
+
+ // 아직 이 기준을 처리하지 않았으면 PQItem 생성
+ if (!groupItems[row.criteriaId]) {
+ groupItems[row.criteriaId] = {
+ answerId: row.answerId,
+ criteriaId: row.criteriaId,
+ code: row.code,
+ checkPoint: row.checkPoint,
+ description: row.description,
+ remarks: row.remarks,
+ answer: row.answer || "",
+ shiComment: row.shiComment || "",
+ vendorReply: row.vendorReply || "",
+ attachments: [],
+ inputFormat: row.inputFormat || "",
+
+ subGroupName: row.subGroupName || "",
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ };
+ }
+
+ // 첨부 파일이 있으면 추가
+ if (row.attachId) {
+ groupItems[row.criteriaId].attachments.push({
+ attachId: row.attachId,
+ fileName: row.fileName || "",
+ filePath: row.filePath || "",
+ fileSize: row.fileSize || undefined,
+ });
+ }
+ }
+
+ // 최종 데이터 구성
+ const data: PQGroupData[] = [];
+ for (const [groupName, itemsMap] of groupMap.entries()) {
+ const items = Object.values(itemsMap);
+ data.push({ groupName, items });
+ }
+
+ return data;
+ }
+}
+
+
+interface PQAttachmentInput {
+ fileName: string // original user-friendly file name
+ url: string // the UUID-based path stored on server
+ size?: number // optional file size
+}
+
+interface SavePQAnswer {
+ criteriaId: number
+ answer: string
+ shiComment?: string
+ vendorReply?: string
+ attachments: PQAttachmentInput[]
+}
+
+interface SavePQInput {
+ vendorId: number
+ projectId?: number
+ answers: SavePQAnswer[]
+}
+
+/**
+ * 여러 항목을 한 번에 Upsert
+ */
+export async function savePQAnswersAction(input: SavePQInput) {
+ const { vendorId, projectId, answers } = input
+
+ try {
+ for (const ans of answers) {
+ // 1) Check if a row already exists for (vendorId, criteriaId, projectId)
+ const queryConditions = [
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId)
+ ];
+
+ // Add projectId condition when it exists
+ if (projectId !== undefined) {
+ queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else {
+ queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
+ }
+
+ const existing = await db
+ .select()
+ .from(vendorPqCriteriaAnswers)
+ .where(and(...queryConditions));
+
+ let answerId: number
+
+ // 2) If it exists, update the row; otherwise insert
+ if (existing.length === 0) {
+ // Insert new
+ const inserted = await db
+ .insert(vendorPqCriteriaAnswers)
+ .values({
+ vendorId,
+ criteriaId: ans.criteriaId,
+ projectId: projectId || null, // Include projectId when provided
+ answer: ans.answer,
+ shiComment: ans.shiComment || null,
+ vendorReply: ans.vendorReply || null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning({ id: vendorPqCriteriaAnswers.id })
+
+ answerId = inserted[0].id
+ } else {
+ // Update existing
+ answerId = existing[0].id
+
+ await db
+ .update(vendorPqCriteriaAnswers)
+ .set({
+ answer: ans.answer,
+ shiComment: ans.shiComment || null,
+ vendorReply: ans.vendorReply || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorPqCriteriaAnswers.id, answerId))
+ }
+
+ // 3) Now manage attachments in vendorCriteriaAttachments
+ // 3a) Load old attachments from DB
+ const oldAttachments = await db
+ .select({
+ id: vendorCriteriaAttachments.id,
+ filePath: vendorCriteriaAttachments.filePath,
+ })
+ .from(vendorCriteriaAttachments)
+ .where(eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, answerId))
+
+ // 3b) Gather the new filePaths (urls) from the client
+ const newPaths = ans.attachments.map(a => a.url)
+
+ // 3c) Find attachments to remove
+ const toRemove = oldAttachments.filter(old => !newPaths.includes(old.filePath))
+ if (toRemove.length > 0) {
+ const removeIds = toRemove.map(r => r.id)
+ await db
+ .delete(vendorCriteriaAttachments)
+ .where(inArray(vendorCriteriaAttachments.id, removeIds))
+ }
+
+ // 3d) Insert new attachments that aren't in DB
+ const oldPaths = oldAttachments.map(o => o.filePath)
+ const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url))
+
+ for (const attach of toAdd) {
+ await db.insert(vendorCriteriaAttachments).values({
+ vendorCriteriaAnswerId: answerId,
+ fileName: attach.fileName,
+ filePath: attach.url,
+ fileSize: attach.size ?? null,
+ })
+ }
+ }
+
+ return { ok: true }
+ } catch (error) {
+ console.error("savePQAnswersAction error:", error)
+ return { ok: false, error: String(error) }
+ }
+}
+
+
+
+/**
+ * PQ 제출 서버 액션 - 협력업체 상태를 PQ_SUBMITTED로 업데이트
+ * @param vendorId 협력업체 ID
+ */
+export async function submitPQAction({
+ vendorId,
+ projectId,
+ pqSubmissionId
+}: {
+ vendorId: number;
+ projectId?: number;
+ pqSubmissionId?: number; // 특정 PQ 제출 ID가 있는 경우 사용
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 1. 모든 PQ 항목에 대한 응답이 있는지 검증
+ const answerQueryConditions = [
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId)
+ ];
+
+ // Add projectId condition when it exists
+ if (projectId !== undefined) {
+ answerQueryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else {
+ answerQueryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
+ }
+
+ const pqCriteriaCount = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(and(...answerQueryConditions));
+
+ const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0;
+
+ // 응답 데이터 검증
+ if (totalPqCriteriaCount === 0) {
+ return { ok: false, error: "No PQ answers found" };
+ }
+
+ // 2. 협력업체 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // Project 정보 조회 (projectId가 있는 경우)
+ let projectName = '';
+ if (projectId) {
+ const projectData = await db
+ .select({
+ projectName: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.projectName || 'Unknown Project';
+ }
+
+ // 3. 현재 PQ 제출 상태 확인 및 업데이트
+ const currentDate = new Date();
+ let existingSubmission;
+
+ // 특정 PQ Submission ID가 있는 경우
+ if (pqSubmissionId) {
+ existingSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!existingSubmission) {
+ return { ok: false, error: "PQ submission not found or access denied" };
+ }
+ }
+ // ID가 없는 경우 vendorId와 projectId로 조회
+ else {
+ const pqType = projectId ? "PROJECT" : "GENERAL";
+
+ const submissionQueryConditions = [
+ eq(vendorPQSubmissions.vendorId, vendorId),
+ eq(vendorPQSubmissions.type, pqType)
+ ];
+
+ if (projectId) {
+ submissionQueryConditions.push(eq(vendorPQSubmissions.projectId, projectId));
+ } else {
+ submissionQueryConditions.push(isNull(vendorPQSubmissions.projectId));
+ }
+
+ existingSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type
+ })
+ .from(vendorPQSubmissions)
+ .where(and(...submissionQueryConditions))
+ .then(rows => rows[0]);
+ }
+
+ // 제출 가능한 상태 확인
+ const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"];
+
+ if (existingSubmission) {
+ if (!allowedStatuses.includes(existingSubmission.status)) {
+ return {
+ ok: false,
+ error: `Cannot submit PQ in current status: ${existingSubmission.status}`
+ };
+ }
+
+ // 기존 제출 상태 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "SUBMITTED",
+ submittedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, existingSubmission.id));
+ } else {
+ // PQ Submission ID가 없고 기존 submission도 없는 경우 새로운 제출 생성
+ const pqType = projectId ? "PROJECT" : "GENERAL";
+
+ // PQ 번호 생성 (예: PQ-2024-001)
+ const currentYear = new Date().getFullYear();
+ const pqNumber = `PQ-${currentYear}-${String(vendorId).padStart(3, '0')}`;
+
+ await db
+ .insert(vendorPQSubmissions)
+ .values({
+ pqNumber,
+ vendorId,
+ projectId: projectId || null,
+ type: pqType,
+ status: "SUBMITTED",
+ submittedAt: currentDate,
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ });
+ }
+
+ // 4. 일반 PQ인 경우 벤더 상태도 업데이트
+ if (!projectId) {
+ const allowedVendorStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
+
+ if (allowedVendorStatuses.includes(vendor.status)) {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_SUBMITTED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+ }
+
+ // 5. 관리자에게 이메일 알림 발송
+ if (process.env.ADMIN_EMAIL) {
+ try {
+ const emailSubject = projectId
+ ? `[eVCP] Project PQ Submitted: ${vendor.vendorName} for ${projectName}`
+ : `[eVCP] General PQ Submitted: ${vendor.vendorName}`;
+
+ const adminUrl = `http://${host}/evcp/pq/${vendorId}/${existingSubmission?.id || ''}`;
+
+ await sendEmail({
+ to: process.env.ADMIN_EMAIL,
+ subject: emailSubject,
+ template: "pq-submitted-admin",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorId: vendor.id,
+ projectId: projectId,
+ projectName: projectName,
+ isProjectPQ: !!projectId,
+ submittedDate: currentDate.toLocaleString(),
+ adminUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send admin notification:", emailError);
+ }
+ }
+
+ // 6. 벤더에게 확인 이메일 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = projectId
+ ? `[eVCP] Project PQ Submission Confirmation for ${projectName}`
+ : "[eVCP] General PQ Submission Confirmation";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-submitted-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: projectId,
+ projectName: projectName,
+ isProjectPQ: !!projectId,
+ submittedDate: currentDate.toLocaleString(),
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor confirmation:", emailError);
+ }
+ }
+
+ // 7. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+
+ if (projectId) {
+ revalidateTag(`project-pq-submissions-${projectId}`);
+ revalidateTag(`project-vendors-${projectId}`);
+ revalidateTag(`project-pq-${projectId}`);
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error("PQ submit error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 향상된 파일 업로드 서버 액션
+ * - 직접 파일 처리 (file 객체로 받음)
+ * - 디렉토리 자동 생성
+ * - 중복 방지를 위한 UUID 적용
+ */
+/**
+ * 벤더용 파일 업로드 액션 (saveFile 사용)
+ */
+export async function uploadVendorFileAction(file: File, userId?: string) {
+ unstable_noStore();
+
+ try {
+ const result = await saveFile({
+ file,
+ directory: 'pq/vendor',
+ originalName: file.name,
+ userId,
+ });
+
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드에 실패했습니다.");
+ }
+
+ return {
+ fileName: result.fileName!,
+ url: result.publicPath!,
+ size: result.fileSize!,
+ };
+ } catch (error) {
+ console.error("Vendor file upload error:", error);
+ throw new Error(`Upload failed: ${getErrorMessage(error)}`);
+ }
+}
+
+/**
+ * SHI용 파일 업로드 액션 (saveDRMFile 사용)
+ */
+export async function uploadSHIFileAction(file: File, userId?: string) {
+ unstable_noStore();
+
+ try {
+ const result = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ 'pq/shi',
+ userId
+ );
+
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드에 실패했습니다.");
+ }
+
+ return {
+ fileName: result.fileName!,
+ url: result.publicPath!,
+ size: result.fileSize!,
+ };
+ } catch (error) {
+ console.error("SHI file upload error:", error);
+ throw new Error(`Upload failed: ${getErrorMessage(error)}`);
+ }
+}
+
+/**
+ * 벤더용 여러 파일 일괄 업로드
+ */
+export async function uploadVendorMultipleFilesAction(files: File[], userId?: string) {
+ unstable_noStore();
+
+ try {
+ const results = [];
+
+ for (const file of files) {
+ try {
+ const result = await uploadVendorFileAction(file, userId);
+ results.push({
+ success: true,
+ ...result
+ });
+ } catch (error) {
+ results.push({
+ success: false,
+ fileName: file.name,
+ error: getErrorMessage(error)
+ });
+ }
+ }
+
+ return {
+ ok: true,
+ results
+ };
+ } catch (error) {
+ console.error("Vendor batch upload error:", error);
+ return {
+ ok: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+/**
+ * SHI용 여러 파일 일괄 업로드
+ */
+export async function uploadSHIMultipleFilesAction(files: File[], userId?: string) {
+ unstable_noStore();
+
+ try {
+ const results = [];
+
+ for (const file of files) {
+ try {
+ const result = await uploadSHIFileAction(file, userId);
+ results.push({
+ success: true,
+ ...result
+ });
+ } catch (error) {
+ results.push({
+ success: false,
+ fileName: file.name,
+ error: getErrorMessage(error)
+ });
+ }
+ }
+
+ return {
+ ok: true,
+ results
+ };
+ } catch (error) {
+ console.error("SHI batch upload error:", error);
+ return {
+ ok: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+export async function getVendorsInPQ(input: GetVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendors,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 2) 글로벌 검색
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendors.vendorName, s),
+ ilike(vendors.vendorCode, s),
+ ilike(vendors.email, s),
+ ilike(vendors.status, s)
+ );
+ }
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 협력업체 ID 모음 (중복 제거용)
+ const vendorIds = new Set<number>();
+
+ // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이)
+ const generalPqVendors = await tx
+ .select({
+ vendorId: vendorPqCriteriaAnswers.vendorId
+ })
+ .from(vendorPqCriteriaAnswers)
+ .innerJoin(
+ vendors,
+ eq(vendorPqCriteriaAnswers.vendorId, vendors.id)
+ )
+ .where(
+ and(
+ isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님)
+ advancedWhere,
+ globalWhere
+ )
+ )
+ .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트
+
+ generalPqVendors.forEach(v => vendorIds.add(v.vendorId));
+
+ // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이)
+ const projectPqVendors = await tx
+ .select({
+ vendorId: vendorPQSubmissions.vendorId
+ })
+ .from(vendorPQSubmissions)
+ .innerJoin(
+ vendors,
+ eq(vendorPQSubmissions.vendorId, vendors.id)
+ )
+ .where(
+ and(
+ eq(vendorPQSubmissions.type, "PROJECT"),
+ // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함
+ not(eq(vendorPQSubmissions.status, "REQUESTED")), // REQUESTED 상태는 제외
+ advancedWhere,
+ globalWhere
+ )
+ );
+
+ projectPqVendors.forEach(v => vendorIds.add(v.vendorId));
+
+ // 중복 제거된 협력업체 ID 배열
+ const uniqueVendorIds = Array.from(vendorIds);
+
+ // 총 개수 (중복 제거 후)
+ const total = uniqueVendorIds.length;
+
+ if (total === 0) {
+ return { data: [], total: 0 };
+ }
+
+ // 페이징 처리 (정렬 후 limit/offset 적용)
+ const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage);
+
+ // 2) 페이징된 협력업체 상세 정보 조회
+ const vendorsData = await selectVendors(tx, {
+ where: inArray(vendors.id, paginatedIds),
+ orderBy: input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName)
+ )
+ : [asc(vendors.createdAt)],
+ });
+
+ // 3) 각 벤더별 PQ 상태 정보 추가
+ const vendorsWithPqInfo = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ // 3-A) 첨부 파일 조회
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지)
+ const generalPqAnswers = await tx
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendor.id),
+ isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ );
+
+ const hasGeneralPq = generalPqAnswers[0]?.count > 0;
+
+ // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함)
+ const projectPqs = await tx
+ .select({
+ projectId: vendorPQSubmissions.projectId,
+ projectName: projects.name,
+ status: vendorPQSubmissions.status,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt
+ })
+ .from(vendorPQSubmissions)
+ .innerJoin(
+ projects,
+ eq(vendorPQSubmissions.projectId, projects.id)
+ )
+ .where(
+ and(
+ eq(vendorPQSubmissions.vendorId, vendor.id),
+ eq(vendorPQSubmissions.type, "PROJECT"),
+ not(eq(vendorPQSubmissions.status, "REQUESTED")) // REQUESTED 상태는 제외
+ )
+ );
+
+ const hasProjectPq = projectPqs.length > 0;
+
+ // 프로젝트 PQ 상태별 카운트
+ const projectPqStatusCounts = {
+ inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length,
+ submitted: projectPqs.filter(p => p.status === "SUBMITTED").length,
+ approved: projectPqs.filter(p => p.status === "APPROVED").length,
+ rejected: projectPqs.filter(p => p.status === "REJECTED").length,
+ total: projectPqs.length
+ };
+
+ // 3-D) PQ 상태 정보 추가
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ pqInfo: {
+ hasGeneralPq,
+ hasProjectPq,
+ projectPqs,
+ projectPqStatusCounts,
+ // 현재 PQ 상태 (UI에 표시 용도)
+ pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts)
+ }
+ };
+ })
+ );
+
+ return { data: vendorsWithPqInfo, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("Error in getVendorsInPQ:", err);
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화
+ }
+ )();
+}
+
+// PQ 상태 표시 함수
+function getPqStatusDisplay(
+ vendorStatus: string,
+ hasGeneralPq: boolean,
+ hasProjectPq: boolean,
+ projectPqCounts: { inProgress: number, submitted: number, approved: number, rejected: number, total: number }
+): string {
+ // 프로젝트 PQ 상태 문자열 생성
+ let projectPqStatus = "";
+ if (hasProjectPq) {
+ const parts = [];
+ if (projectPqCounts.inProgress > 0) {
+ parts.push(`진행중: ${projectPqCounts.inProgress}`);
+ }
+ if (projectPqCounts.submitted > 0) {
+ parts.push(`제출: ${projectPqCounts.submitted}`);
+ }
+ if (projectPqCounts.approved > 0) {
+ parts.push(`승인: ${projectPqCounts.approved}`);
+ }
+ if (projectPqCounts.rejected > 0) {
+ parts.push(`거부: ${projectPqCounts.rejected}`);
+ }
+ projectPqStatus = parts.join(", ");
+ }
+
+ // 일반 PQ + 프로젝트 PQ 조합 상태
+ if (hasGeneralPq && hasProjectPq) {
+ return `일반 PQ (${getPqVendorStatusText(vendorStatus)}) + 프로젝트 PQ (${projectPqStatus})`;
+ } else if (hasGeneralPq) {
+ return `일반 PQ (${getPqVendorStatusText(vendorStatus)})`;
+ } else if (hasProjectPq) {
+ return `프로젝트 PQ (${projectPqStatus})`;
+ }
+
+ return "PQ 정보 없음";
+}
+
+// 협력업체 상태 텍스트 변환
+function getPqVendorStatusText(status: string): string {
+ switch (status) {
+ case "IN_PQ": return "진행중";
+ case "PQ_SUBMITTED": return "제출됨";
+ case "PQ_FAILED": return "실패";
+ case "PQ_APPROVED":
+ case "APPROVED": return "승인됨";
+ case "READY_TO_SEND": return "거래 준비";
+ case "ACTIVE": return "활성";
+ case "INACTIVE": return "비활성";
+ case "BLACKLISTED": return "거래금지";
+ default: return status;
+ }
+}
+
+
+export type VendorStatus =
+ | "PENDING_REVIEW"
+ | "IN_REVIEW"
+ | "REJECTED"
+ | "IN_PQ"
+ | "PQ_SUBMITTED"
+ | "PQ_FAILED"
+ | "APPROVED"
+ | "ACTIVE"
+ | "INACTIVE"
+ | "BLACKLISTED"
+ | "PQ_APPROVED"
+
+ export async function updateVendorStatusAction(
+ vendorId: number,
+ newStatus: VendorStatus
+ ) {
+ try {
+ // 1) Update DB
+ await db.update(vendors)
+ .set({ status: newStatus })
+ .where(eq(vendors.id, vendorId))
+
+ // 2) Load vendor's email & name
+ const vendor = await db.select().from(vendors).where(eq(vendors.id, vendorId)).then(r => r[0])
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" }
+ }
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const loginUrl = `http://${host}/partners/pq`
+
+ // 3) Send email
+ await sendEmail({
+ to: vendor.email || "",
+ subject: `Your PQ Status is now ${newStatus}`,
+ template: "vendor-pq-status", // matches .hbs file
+ context: {
+ name: vendor.vendorName,
+ status: newStatus,
+ loginUrl: loginUrl, // etc.
+ },
+ })
+ revalidateTag("vendors")
+ revalidateTag("vendors-in-pq")
+ return { ok: true }
+ } catch (error) {
+ console.error("updateVendorStatusAction error:", error)
+ return { ok: false, error: String(error) }
+ }
+ }
+
+ type ProjectPQStatus = "REQUESTED" | "IN_PROGRESS" | "SUBMITTED" | "APPROVED" | "REJECTED";
+
+/**
+ * Update the status of a project-specific PQ for a vendor
+ */
+export async function updateProjectPQStatusAction({
+ vendorId,
+ projectId,
+ status,
+ comment
+}: {
+ vendorId: number;
+ projectId: number;
+ status: ProjectPQStatus;
+ comment?: string;
+}) {
+ try {
+ const currentDate = new Date();
+
+ // 1) Prepare update data with appropriate timestamps
+ const updateData: any = {
+ status,
+ updatedAt: currentDate,
+ };
+
+ // Add status-specific fields
+ if (status === "APPROVED") {
+ updateData.approvedAt = currentDate;
+ } else if (status === "REJECTED") {
+ updateData.rejectedAt = currentDate;
+ updateData.rejectReason = comment || null;
+ } else if (status === "SUBMITTED") {
+ updateData.submittedAt = currentDate;
+ }
+
+ // 2) Update the project PQ record
+ await db
+ .update(vendorPQSubmissions)
+ .set(updateData)
+ .where(
+ and(
+ eq(vendorPQSubmissions.vendorId, vendorId),
+ eq(vendorPQSubmissions.projectId, projectId),
+ eq(vendorPQSubmissions.type, "PROJECT")
+ )
+ );
+
+ // 3) Load vendor and project details for email
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ email: vendors.email,
+ vendorName: vendors.vendorName
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ const project = await db
+ .select({
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .then(rows => rows[0]);
+
+ if (!project) {
+ return { ok: false, error: "Project not found" };
+ }
+
+ // 4) Send email notification
+ await sendEmail({
+ to: vendor.email || "",
+ subject: `Your Project PQ for ${project.name} is now ${status}`,
+ template: "vendor-project-pq-status", // matches .hbs file (you might need to create this)
+ context: {
+ name: vendor.vendorName,
+ status,
+ projectName: project.name,
+ rejectionReason: status === "REJECTED" ? comment : undefined,
+ hasRejectionReason: status === "REJECTED" && !!comment,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`,
+ approvalDate: status === "APPROVED" ? currentDate.toLocaleDateString() : undefined,
+ rejectionDate: status === "REJECTED" ? currentDate.toLocaleDateString() : undefined,
+ },
+ });
+
+ // 5) Revalidate cache tags
+ revalidateTag("vendors");
+ revalidateTag("vendors-in-pq");
+ revalidateTag(`vendor-project-pqs-${vendorId}`);
+ revalidateTag(`project-pq-${projectId}`);
+ revalidateTag(`project-vendors-${projectId}`);
+
+ return { ok: true };
+ } catch (error) {
+ console.error("updateProjectPQStatusAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+
+// 코멘트 타입 정의
+interface ItemComment {
+ answerId: number;
+ checkPoint: string; // 체크포인트 정보 추가
+ code: string; // 코드 정보 추가
+ comment: string;
+}
+
+/**
+ * PQ 변경 요청 처리 서버 액션
+ *
+ * @param vendorId 협력업체 ID
+ * @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성)
+ * @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항)
+ */
+export async function requestPqChangesAction({
+ vendorId,
+ projectId,
+ comment,
+ generalComment,
+ reviewerName
+}: {
+ vendorId: number;
+ projectId?: number; // Optional project ID for project-specific PQs
+ comment: ItemComment[];
+ generalComment?: string;
+ reviewerName?: string;
+}) {
+ try {
+ // 1) 상태 업데이트 (PQ 타입에 따라 다르게 처리)
+ if (projectId) {
+ // 프로젝트 PQ인 경우 vendorPQSubmissions 테이블 업데이트
+ const projectPq = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.vendorId, vendorId),
+ eq(vendorPQSubmissions.projectId, projectId),
+ eq(vendorPQSubmissions.type, "PROJECT")
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!projectPq) {
+ return { ok: false, error: "Project PQ record not found" };
+ }
+
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "IN_PROGRESS", // 변경 요청 상태로 설정
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(vendorPQSubmissions.vendorId, vendorId),
+ eq(vendorPQSubmissions.projectId, projectId),
+ eq(vendorPQSubmissions.type, "PROJECT")
+ )
+ );
+ } else {
+ // 일반 PQ인 경우 vendors 테이블 업데이트
+ await db
+ .update(vendors)
+ .set({
+ status: "IN_PQ", // 변경 요청 상태로 설정
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 2) 협력업체 정보 가져오기
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(r => r[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 프로젝트 정보 가져오기 (프로젝트 PQ인 경우)
+ let projectName = "";
+ if (projectId) {
+ const project = await db
+ .select({
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .then(rows => rows[0]);
+
+ projectName = project?.name || "Unknown Project";
+ }
+
+ // 3) 각 항목별 코멘트 저장
+ const currentDate = new Date();
+
+
+ // 병렬로 모든 코멘트 저장
+ if (comment && comment.length > 0) {
+ const insertPromises = comment.map(item =>
+ db.insert(vendorPqReviewLogs)
+ .values({
+ vendorPqCriteriaAnswerId: item.answerId,
+ // reviewerId: reviewerId,
+ reviewerName: reviewerName,
+ reviewerComment: item.comment,
+ createdAt: currentDate,
+ // 추가 메타데이터 필드가 있다면 저장
+ // 이런 메타데이터는 DB 스키마에 해당 필드가 있어야 함
+ // meta: JSON.stringify({ checkPoint: item.checkPoint, code: item.code })
+ })
+ );
+
+ // 모든 삽입 기다리기
+ await Promise.all(insertPromises);
+ }
+
+ // 4) 변경 요청 이메일 보내기
+ // 코멘트 목록 준비
+ const commentItems = comment.map(item => ({
+ id: item.answerId,
+ code: item.code,
+ checkPoint: item.checkPoint,
+ text: item.comment
+ }));
+
+ // PQ 유형에 따라 이메일 제목 및 내용 조정
+ const emailSubject = projectId
+ ? `[IMPORTANT] Your Project PQ (${projectName}) requires changes`
+ : `[IMPORTANT] Your PQ submission requires changes`;
+
+ // 로그인 URL - 프로젝트 PQ인 경우 다른 경로로 안내
+ const loginUrl = projectId
+ ? `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`
+ : `${process.env.NEXT_PUBLIC_URL}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email || "",
+ subject: emailSubject,
+ template: "vendor-pq-comment", // matches .hbs file
+ context: {
+ name: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ loginUrl,
+ comments: commentItems,
+ generalComment: generalComment || "",
+ hasGeneralComment: !!generalComment,
+ commentCount: commentItems.length,
+ projectId,
+ projectName,
+ isProjPQ: !!projectId,
+ },
+ });
+
+ // 5) 캐시 무효화 - PQ 유형에 따라 적절한 태그 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendors-in-pq");
+
+ if (projectId) {
+ revalidateTag(`vendor-project-pqs-${vendorId}`);
+ revalidateTag(`project-pq-${projectId}`);
+ revalidateTag(`project-vendors-${projectId}`);
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error("requestPqChangesAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+
+interface AddReviewCommentInput {
+ answerId: number // vendorPqCriteriaAnswers.id
+ comment: string
+ reviewerName?: string
+}
+
+export async function addReviewCommentAction(input: AddReviewCommentInput) {
+ try {
+ // 1) Check that the answer row actually exists
+ const existing = await db
+ .select({ id: vendorPqCriteriaAnswers.id })
+ .from(vendorPqCriteriaAnswers)
+ .where(eq(vendorPqCriteriaAnswers.id, input.answerId))
+
+ if (existing.length === 0) {
+ return { ok: false, error: "Item not found" }
+ }
+
+ // 2) Insert the log
+ await db.insert(vendorPqReviewLogs).values({
+ vendorPqCriteriaAnswerId: input.answerId,
+ reviewerComment: input.comment,
+ reviewerName: input.reviewerName ?? "AdminUser",
+ })
+
+ return { ok: true }
+ } catch (error) {
+ console.error("addReviewCommentAction error:", error)
+ return { ok: false, error: String(error) }
+ }
+}
+
+interface GetItemReviewLogsInput {
+ answerId: number
+}
+
+export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) {
+ try {
+
+ const logs = await db
+ .select()
+ .from(vendorPqReviewLogs)
+ .where(eq(vendorPqReviewLogs.vendorPqCriteriaAnswerId, input.answerId))
+ .orderBy(desc(vendorPqReviewLogs.createdAt));
+
+ return { ok: true, data: logs };
+ } catch (error) {
+ console.error("getItemReviewLogsAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+
+export interface VendorPQListItem {
+ projectId: number;
+ projectName: string;
+ status: string;
+ submittedAt?: Date | null; // Change to accept both undefined and null
+}
+
+export interface VendorPQsList {
+ hasGeneralPq: boolean;
+ generalPqStatus?: string; // vendor.status for general PQ
+ projectPQs: VendorPQListItem[];
+}
+
+export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList> {
+ try {
+ // 1. Check if vendor has general PQ answers
+ const generalPqAnswers = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ );
+
+ const hasGeneralPq = (generalPqAnswers[0]?.count || 0) > 0;
+
+ // 2. Get vendor status for general PQ
+ let generalPqStatus;
+ if (hasGeneralPq) {
+ const vendor = await db
+ .select({ status: vendors.status })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ generalPqStatus = vendor?.status;
+ }
+
+ // 3. Get project PQs
+ const projectPQs = await db
+ .select({
+ projectId: vendorPQSubmissions.projectId,
+ projectName: projects.name,
+ status: vendorPQSubmissions.status,
+ submittedAt: vendorPQSubmissions.submittedAt
+ })
+ .from(vendorPQSubmissions)
+ .innerJoin(
+ projects,
+ eq(vendorPQSubmissions.projectId, projects.id)
+ )
+ .where(
+ and(
+ eq(vendorPQSubmissions.vendorId, vendorId),
+ eq(vendorPQSubmissions.type, "PROJECT"),
+ not(eq(vendorPQSubmissions.status, "REQUESTED")) // Exclude requests that haven't been started
+ )
+ )
+ .orderBy(vendorPQSubmissions.updatedAt);
+
+ return {
+ hasGeneralPq,
+ generalPqStatus,
+ projectPQs: projectPQs
+ };
+
+ } catch (error) {
+ console.error("Error fetching vendor PQs list:", error);
+ return {
+ hasGeneralPq: false,
+ projectPQs: []
+ };
+ }
+}
+
+
+export async function loadGeneralPQData(vendorId: number) {
+ "use server";
+ return getPQDataByVendorId(vendorId)
+}
+
+export async function loadProjectPQData(vendorId: number, projectId: number) {
+ "use server";
+ return getPQDataByVendorId(vendorId, projectId)
+}
+
+export async function loadGeneralPQAction(vendorId: number) {
+ return getPQDataByVendorId(vendorId);
+}
+
+export async function loadProjectPQAction(vendorId: number, projectId?: number): Promise<PQGroupData[]> {
+ if (!projectId) {
+ throw new Error("Project ID is required for loading project PQ data");
+ }
+ return getPQDataByVendorId(vendorId, projectId);
+}
+
+
+
+export async function getAllPQsByVendorId(vendorId: number) {
+ try {
+ const pqList = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ projectId: vendorPQSubmissions.projectId,
+ projectName: projects.name,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+ rejectReason: vendorPQSubmissions.rejectReason,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(desc(vendorPQSubmissions.createdAt));
+
+ return pqList;
+ } catch (error) {
+ console.error("Error fetching PQ list:", error);
+ return [];
+ }
+}
+
+// 특정 PQ의 상세 정보 조회 (개별 PQ 페이지용)
+export async function getPQById(pqSubmissionId: number, vendorId: number) {
+ try {
+ const pq = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ createdAt: vendorPQSubmissions.createdAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+ rejectReason: vendorPQSubmissions.rejectReason,
+
+ // 벤더 정보 (추가)
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorStatus: vendors.status,
+
+ // 프로젝트 정보 (조인)
+ projectName: projects.name,
+ projectCode: projects.code,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!pq) {
+ throw new Error("PQ not found or access denied");
+ }
+
+ return pq;
+ } catch (error) {
+ console.error("Error fetching PQ by ID:", error);
+ throw error;
+ }
+}
+
+export async function getPQStatusCounts(vendorId: number) {
+ try {
+ // 모든 PQ 상태 조회 (일반 PQ + 프로젝트 PQ)
+ const pqStatuses = await db
+ .select({
+ status: vendorPQSubmissions.status,
+ count: count(),
+ })
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .groupBy(vendorPQSubmissions.status);
+
+ // 상태별 개수를 객체로 변환
+ const statusCounts = {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+
+ // 조회된 결과를 statusCounts 객체에 매핑
+ pqStatuses.forEach((item) => {
+ if (item.status in statusCounts) {
+ statusCounts[item.status as keyof typeof statusCounts] = item.count;
+ }
+ });
+
+ return statusCounts;
+ } catch (error) {
+ console.error("Error fetching PQ status counts:", error);
+ return {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+ }
+}
+
+// 상태 레이블 함수
+function getStatusLabel(status: string): string {
+ switch (status) {
+ case "REQUESTED":
+ return "요청됨";
+ case "IN_PROGRESS":
+ return "진행 중";
+ case "SUBMITTED":
+ return "제출됨";
+ case "APPROVED":
+ return "승인됨";
+ case "REJECTED":
+ return "거부됨";
+ default:
+ return status;
+ }
+}
+
+export async function getPQSubmissions(input: GetPQSubmissionsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const pqFilterMapping = createPQFilterMapping();
+ const joinedTables = getPQJoinedTables();
+
+ console.log(input, "input")
+
+ // 1) 고급 필터 조건 (DataTableAdvancedToolbar에서)
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: vendorPQSubmissions,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ joinedTables,
+ customColumnMapping: pqFilterMapping,
+ });
+ console.log("advancedWhere:", advancedWhere);
+ }
+
+ // 2) 기본 필터 조건 (PQFilterSheet에서)
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: vendorPQSubmissions,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ joinedTables,
+ customColumnMapping: pqFilterMapping,
+ });
+ console.log("basicWhere:", basicWhere);
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ // 기존 검색 조건들
+ const nameCondition = ilike(vendors.vendorName, s);
+ if (nameCondition) validSearchConditions.push(nameCondition);
+
+ const codeCondition = ilike(vendors.vendorCode, s);
+ if (codeCondition) validSearchConditions.push(codeCondition);
+
+ const projectNameCondition = ilike(projects.name, s);
+ if (projectNameCondition) validSearchConditions.push(projectNameCondition);
+
+ const projectCodeCondition = ilike(projects.code, s);
+ if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
+
+ // 새로 추가된 검색 조건들
+ const pqNumberCondition = ilike(vendorPQSubmissions.pqNumber, s);
+ if (pqNumberCondition) validSearchConditions.push(pqNumberCondition);
+
+ const requesterCondition = ilike(users.name, s);
+ if (requesterCondition) validSearchConditions.push(requesterCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // 4) 날짜 조건
+ let fromDateWhere: SQL<unknown> | undefined = undefined;
+ let toDateWhere: SQL<unknown> | undefined = undefined;
+
+ if (input.submittedDateFrom) {
+ const fromDate = new Date(input.submittedDateFrom);
+ const condition = gte(vendorPQSubmissions.submittedAt, fromDate);
+ if (condition) fromDateWhere = condition;
+ }
+
+ if (input.submittedDateTo) {
+ const toDate = new Date(input.submittedDateTo);
+ const condition = lte(vendorPQSubmissions.submittedAt, toDate);
+ if (condition) toDateWhere = condition;
+ }
+
+ // 5) 최종 WHERE 조건 생성 - 각 그룹을 AND로 연결
+ const whereConditions: SQL<unknown>[] = [];
+
+ // 고급 필터 조건 추가
+ if (advancedWhere) whereConditions.push(advancedWhere);
+
+ // 기본 필터 조건 추가
+ if (basicWhere) whereConditions.push(basicWhere);
+
+ // 기타 조건들 추가
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (fromDateWhere) whereConditions.push(fromDateWhere);
+ if (toDateWhere) whereConditions.push(toDateWhere);
+
+ // 모든 조건을 AND로 연결
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ console.log("Final WHERE conditions:", {
+ advancedWhere: !!advancedWhere,
+ basicWhere: !!basicWhere,
+ globalWhere: !!globalWhere,
+ dateConditions: !!(fromDateWhere || toDateWhere),
+ totalConditions: whereConditions.length
+ });
+
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(vendorPQSubmissions)
+ .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id))
+ .leftJoin(vendorInvestigations, eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id))
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof vendorPQSubmissions.$inferSelect;
+ return sort.desc ? desc(vendorPQSubmissions[column]) : asc(vendorPQSubmissions[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(vendorPQSubmissions.updatedAt));
+ }
+
+ const pqSubmissions = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ type: vendorPQSubmissions.type,
+ pqNumber: vendorPQSubmissions.pqNumber,
+ requesterId: vendorPQSubmissions.requesterId,
+ requesterName: users.name,
+ status: vendorPQSubmissions.status,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ submittedAt: vendorPQSubmissions.submittedAt,
+ approvedAt: vendorPQSubmissions.approvedAt,
+ rejectedAt: vendorPQSubmissions.rejectedAt,
+ rejectReason: vendorPQSubmissions.rejectReason,
+ pqItems: vendorPQSubmissions.pqItems,
+ // Vendor 정보
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ email: vendors.email,
+ taxId: vendors.taxId,
+ vendorStatus: vendors.status,
+ // Project 정보 (프로젝트 PQ인 경우)
+ projectId: projects.id,
+ projectName: projects.name,
+ projectCode: projects.code,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id))
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 8) 각 PQ 제출에 대한 추가 정보 조회 (기존과 동일)
+ const pqSubmissionsWithDetails = await Promise.all(
+ pqSubmissions.map(async (submission) => {
+ // 기본 반환 객체
+ const baseResult = {
+ ...submission,
+ answerCount: 0,
+ attachmentCount: 0,
+ pqStatus: getStatusLabel(submission.status),
+ pqTypeLabel: submission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ",
+ };
+
+ // vendorId가 null이면 기본 정보만 반환
+ if (submission.vendorId === null) {
+ return baseResult;
+ }
+
+ try {
+ // 답변 수 조회
+ const vendorId = submission.vendorId;
+
+ const answerWhereConditions: SQL<unknown>[] = [];
+
+ const vendorCondition = eq(vendorPqCriteriaAnswers.vendorId, vendorId);
+ if (vendorCondition) answerWhereConditions.push(vendorCondition);
+
+ let projectCondition: SQL<unknown> | undefined;
+ if (submission.projectId !== null) {
+ projectCondition = eq(vendorPqCriteriaAnswers.projectId, submission.projectId);
+ } else {
+ projectCondition = isNull(vendorPqCriteriaAnswers.projectId);
+ }
+
+ if (projectCondition) answerWhereConditions.push(projectCondition);
+
+ const answerWhere = and(...answerWhereConditions);
+
+ const answersResult = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(answerWhere);
+
+ const answerCount = answersResult[0]?.count || 0;
+
+ // 첨부 파일 수 조회
+ const attachmentsResult = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, vendorPqCriteriaAnswers.id)
+ )
+ .where(answerWhere);
+
+ const attachmentCount = attachmentsResult[0]?.count || 0;
+
+ const requesters = alias(users, 'requesters');
+ const qmManagers = alias(users, 'qmManagers');
+
+ const investigationResult = await db
+ .select({
+ id: vendorInvestigations.id,
+ investigationStatus: vendorInvestigations.investigationStatus,
+ evaluationType: vendorInvestigations.evaluationType,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ investigationMethod: vendorInvestigations.investigationMethod,
+ scheduledStartAt: vendorInvestigations.scheduledStartAt,
+ scheduledEndAt: vendorInvestigations.scheduledEndAt,
+ requestedAt: vendorInvestigations.requestedAt,
+ confirmedAt: vendorInvestigations.confirmedAt,
+ completedAt: vendorInvestigations.completedAt,
+ forecastedAt: vendorInvestigations.forecastedAt,
+ evaluationScore: vendorInvestigations.evaluationScore,
+ evaluationResult: vendorInvestigations.evaluationResult,
+ investigationNotes: vendorInvestigations.investigationNotes,
+ requesterId: vendorInvestigations.requesterId,
+ requesterName: requesters.name,
+ qmManagerId: vendorInvestigations.qmManagerId,
+ qmManagerName: qmManagers.name,
+ qmManagerEmail: qmManagers.email,
+ })
+ .from(vendorInvestigations)
+ .leftJoin(requesters, eq(vendorInvestigations.requesterId, requesters.id))
+ .leftJoin(qmManagers, eq(vendorInvestigations.qmManagerId, qmManagers.id))
+ .where(and(
+ eq(vendorInvestigations.vendorId, submission.vendorId),
+ eq(vendorInvestigations.pqSubmissionId, submission.id)
+ ))
+ .orderBy(desc(vendorInvestigations.createdAt))
+ .limit(1);
+
+ const investigation = investigationResult[0] || null;
+
+ // investigation이 있으면 해당 investigation의 최신 siteVisitRequest 조회
+ let siteVisitRequestId: number | null = null;
+ if (investigation) {
+ const siteVisitRequestResult = await db
+ .select({ id: siteVisitRequests.id })
+ .from(siteVisitRequests)
+ .where(eq(siteVisitRequests.investigationId, investigation.id))
+ .orderBy(desc(siteVisitRequests.createdAt))
+ .limit(1);
+
+ siteVisitRequestId = siteVisitRequestResult[0]?.id || null;
+ }
+
+ return {
+ ...baseResult,
+ answerCount,
+ attachmentCount,
+ siteVisitRequestId,
+ investigation
+ };
+ } catch (error) {
+ console.error("Error fetching PQ details:", error);
+ return baseResult;
+ }
+ })
+ );
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: pqSubmissionsWithDetails, pageCount };
+ } catch (err) {
+ console.error("Error in getPQSubmissions:", err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["pq-submissions"], // revalidateTag 호출 시 무효화
+ }
+ )();
+}
+
+export async function getPQStatusCountsAll() {
+ try {
+ // 모든 PQ 상태별 개수 조회 (벤더 제한 없음)
+ const pqStatuses = await db
+ .select({
+ status: vendorPQSubmissions.status,
+ count: count(),
+ })
+ .from(vendorPQSubmissions)
+ .groupBy(vendorPQSubmissions.status);
+
+ // 상태별 개수를 객체로 변환
+ const statusCounts = {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+
+ // 조회된 결과를 statusCounts 객체에 매핑
+ pqStatuses.forEach((item) => {
+ if (item.status in statusCounts) {
+ statusCounts[item.status as keyof typeof statusCounts] = item.count;
+ }
+ });
+
+ return statusCounts;
+ } catch (error) {
+ console.error("Error fetching PQ status counts:", error);
+ return {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ };
+ }
+}
+
+// PQ 타입별, 상태별 개수 집계 함수 (추가 옵션)
+export async function getPQDetailedStatusCounts() {
+ try {
+ // 타입별, 상태별 개수 조회
+ const pqStatuses = await db
+ .select({
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ count: count(),
+ })
+ .from(vendorPQSubmissions)
+ .groupBy(vendorPQSubmissions.type, vendorPQSubmissions.status);
+
+ // 결과를 저장할 객체 초기화
+ const result = {
+ GENERAL: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ PROJECT: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ total: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ }
+ };
+
+ // 결과 매핑
+ pqStatuses.forEach((item) => {
+ if (item.type && item.status) {
+ const type = item.type as keyof typeof result;
+ const status = item.status as keyof typeof result.GENERAL;
+
+ if (type in result && status in result[type]) {
+ // 타입별 상태 카운트 업데이트
+ result[type][status] = item.count;
+
+ // 타입별 합계 업데이트
+ result[type].total += item.count;
+
+ // 전체 상태별 카운트 업데이트
+ result.total[status] += item.count;
+
+ // 전체 합계 업데이트
+ result.total.total += item.count;
+ }
+ }
+ });
+
+ return result;
+ } catch (error) {
+ console.error("Error fetching detailed PQ status counts:", error);
+ return {
+ GENERAL: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ PROJECT: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ },
+ total: {
+ REQUESTED: 0,
+ IN_PROGRESS: 0,
+ SUBMITTED: 0,
+ APPROVED: 0,
+ REJECTED: 0,
+ total: 0
+ }
+ };
+ }
+}
+
+/**
+ * SHI 코멘트 업데이트 액션
+ */
+export async function updateSHICommentAction({
+ answerId,
+ shiComment,
+}: {
+ answerId: number;
+ shiComment: string;
+}) {
+ try {
+ await db
+ .update(vendorPqCriteriaAnswers)
+ .set({
+ shiComment,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorPqCriteriaAnswers.id, answerId));
+
+ return { ok: true };
+ } catch (error) {
+ console.error("updateSHICommentAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+
+// PQ 승인 액션
+export async function approvePQAction({
+ pqSubmissionId,
+ vendorId,
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const currentDate = new Date();
+
+ // 1. PQ 제출 정보 조회
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ // 2. 상태 확인 (SUBMITTED 상태만 승인 가능)
+ if (pqSubmission.status !== "SUBMITTED") {
+ return {
+ ok: false,
+ error: `Cannot approve PQ in current status: ${pqSubmission.status}`
+ };
+ }
+
+ // 3. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
+ let projectName = '';
+ if (pqSubmission.projectId) {
+ const projectData = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, pqSubmission.projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.name || 'Unknown Project';
+ }
+
+ // 5. PQ 상태 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "APPROVED",
+ approvedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_APPROVED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 7. 벤더에게 이메일 알림 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = pqSubmission.projectId
+ ? `[eVCP] Project PQ Approved for ${projectName}`
+ : "[eVCP] General PQ Approved";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-approved-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: pqSubmission.projectId,
+ projectName: projectName,
+ isProjectPQ: !!pqSubmission.projectId,
+ approvedDate: currentDate.toLocaleString(),
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor notification:", emailError);
+ // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ // 8. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("pq-submissions");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+
+ if (pqSubmission.projectId) {
+ revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
+ revalidateTag(`project-vendors-${pqSubmission.projectId}`);
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error("PQ approve error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+// PQ 거부 액션
+export async function rejectPQAction({
+ pqSubmissionId,
+ vendorId,
+ rejectReason
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+ rejectReason: string;
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const currentDate = new Date();
+
+ // 1. PQ 제출 정보 조회
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ // 2. 상태 확인 (SUBMITTED 상태만 거부 가능)
+ if (pqSubmission.status !== "SUBMITTED") {
+ return {
+ ok: false,
+ error: `Cannot reject PQ in current status: ${pqSubmission.status}`
+ };
+ }
+
+ // 3. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
+ let projectName = '';
+ if (pqSubmission.projectId) {
+ const projectData = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, pqSubmission.projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.name || 'Unknown Project';
+ }
+
+ // 5. PQ 상태 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "REJECTED",
+ rejectedAt: currentDate,
+ rejectReason: rejectReason,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_FAILED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 7. 벤더에게 이메일 알림 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = pqSubmission.projectId
+ ? `[eVCP] Project PQ Rejected for ${projectName}`
+ : "[eVCP] General PQ Rejected";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-rejected-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: pqSubmission.projectId,
+ projectName: projectName,
+ isProjectPQ: !!pqSubmission.projectId,
+ rejectedDate: currentDate.toLocaleString(),
+ rejectReason: rejectReason,
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor notification:", emailError);
+ // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ // 8. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("pq-submissions");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+
+ if (pqSubmission.projectId) {
+ revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
+ revalidateTag(`project-vendors-${pqSubmission.projectId}`);
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error("PQ reject error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+
+// 실사 의뢰 생성 서버 액션
+export async function requestInvestigationAction(
+ pqSubmissionIds: number[],
+ data: {
+ evaluationType: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationNotes?: string
+ }
+) {
+ try {
+ // 세션에서 요청자 정보 가져오기
+ const session = await getServerSession(authOptions);
+ const requesterId = session?.user?.id ? Number(session.user.id) : null;
+
+ if (!requesterId) {
+ return { success: false, error: "인증된 사용자만 실사를 의뢰할 수 있습니다." };
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // PQ 제출 정보 조회
+ const pqSubmissions = await tx
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ inArray(vendorPQSubmissions.id, pqSubmissionIds),
+ eq(vendorPQSubmissions.status, "APPROVED")
+ )
+ );
+
+ if (pqSubmissions.length === 0) {
+ throw new Error("승인된 PQ 제출 항목이 없습니다.");
+ }
+
+ const now = new Date();
+
+ // 각 PQ에 대한 실사 요청 생성 - 타입이 정확히 맞는지 확인
+ const investigations = pqSubmissions.map((pq) => {
+ return {
+ vendorId: pq.vendorId,
+ pqSubmissionId: pq.id,
+ investigationStatus: "PLANNED" as const, // enum 타입으로 명시적 지정
+ evaluationType: data.evaluationType,
+ qmManagerId: data.qmManagerId,
+ forecastedAt: data.forecastedAt,
+ investigationAddress: data.investigationAddress,
+ investigationNotes: data.investigationNotes || null,
+ requesterId: requesterId,
+ requestedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ };
+ });
+
+ // 실사 요청 저장
+ const created = await tx
+ .insert(vendorInvestigations)
+ .values(investigations)
+ .returning();
+
+ return created;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("pq-submissions");
+
+ return {
+ success: true,
+ count: result.length,
+ data: result
+ };
+ } catch (err) {
+ console.error("실사 의뢰 중 오류 발생:", err);
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
+ };
+ }
+}
+
+// 실사 의뢰 취소 서버 액션
+export async function cancelInvestigationAction(investigationIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+ const userId = session?.user?.id ? Number(session.user.id) : null
+
+ if (!userId) {
+ return { success: false, error: "인증된 사용자만 실사를 취소할 수 있습니다." }
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // PLANNED 상태인 실사만 취소 가능
+ const updatedInvestigations = await tx
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "CANCELED",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ inArray(vendorInvestigations.id, investigationIds),
+ eq(vendorInvestigations.investigationStatus, "PLANNED")
+ )
+ )
+ .returning()
+
+ return updatedInvestigations
+ })
+
+ // 캐시 무효화
+ revalidateTag("vendor-investigations")
+ revalidateTag("pq-submissions")
+
+ return {
+ success: true,
+ count: result.length,
+ data: result
+ }
+ } catch (err) {
+ console.error("실사 취소 중 오류 발생:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
+ }
+ }
+}
+
+// 실사 결과 발송 서버 액션
+export async function sendInvestigationResultsAction(input: {
+ investigationIds: number[];
+ purchaseComment?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+ const userId = session?.user?.id ? Number(session.user.id) : null
+
+ if (!userId) {
+ return { success: false, error: "인증된 사용자만 실사 결과를 발송할 수 있습니다." }
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 완료된 실사만 결과 발송 가능
+ const investigations = await tx
+ .select({
+ id: vendorInvestigations.id,
+ vendorId: vendorInvestigations.vendorId,
+ pqSubmissionId: vendorInvestigations.pqSubmissionId,
+ evaluationResult: vendorInvestigations.evaluationResult,
+ investigationNotes: vendorInvestigations.investigationNotes,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ investigationMethod: vendorInvestigations.investigationMethod,
+ confirmedAt: vendorInvestigations.confirmedAt,
+ // Vendor 정보
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ vendorEmail: vendors.email,
+ // PQ 정보
+ pqNumber: vendorPQSubmissions.pqNumber,
+ pqItems: vendorPQSubmissions.pqItems,
+ projectCode: projects.code,
+ projectName: projects.name,
+ // 발신자 정보
+ senderName: users.name,
+ senderEmail: users.email,
+ })
+ .from(vendorInvestigations)
+ .leftJoin(vendors, eq(vendorInvestigations.vendorId, vendors.id))
+ .leftJoin(vendorPQSubmissions, eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id))
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .leftJoin(users, eq(vendorInvestigations.requesterId, users.id))
+ .where(
+ and(
+ inArray(vendorInvestigations.id, input.investigationIds),
+ eq(vendorInvestigations.investigationStatus, "COMPLETED")
+ )
+ )
+
+ if (investigations.length === 0) {
+ throw new Error("발송할 수 있는 완료된 실사가 없습니다.")
+ }
+
+ // 각 실사에 대해 이메일 발송
+ const emailResults = await Promise.all(
+ investigations.map(async (investigation) => {
+ try {
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ // 기본 정보
+ pqNumber: investigation.pqNumber || "N/A",
+ vendorCode: investigation.vendorCode || "N/A",
+ vendorName: investigation.vendorName || "N/A",
+
+ // 실사 정보
+ auditItem: investigation.pqItems || investigation.projectName || "N/A",
+ auditFactoryAddress: investigation.investigationAddress || "N/A",
+ auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
+ auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
+ investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" :
+ investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A",
+ additionalNotes: input.purchaseComment || investigation.investigationNotes || "",
+
+ // 발신자 정보
+ senderName: investigation.senderName || "삼성중공업",
+ senderEmail: investigation.senderEmail || "procurement@samsung.com",
+
+ // 이메일 제목
+ subject: `[SHI Audit] 실사 결과 안내 _ ${investigation.vendorName} _ PQ No. ${investigation.pqNumber}`,
+ }
+
+ // 이메일 발송
+ await sendEmail({
+ to: investigation.vendorEmail,
+ subject: emailContext.subject,
+ template: "audit-result-notice",
+ context: emailContext,
+ })
+
+ return { success: true, investigationId: investigation.id }
+ } catch (error) {
+ console.error(`실사 ID ${investigation.id} 이메일 발송 실패:`, error)
+ return { success: false, investigationId: investigation.id, error: error instanceof Error ? error.message : "알 수 없는 오류" }
+ }
+ })
+ )
+
+ // 성공한 실사들의 상태를 RESULT_SENT로 업데이트
+ const successfulInvestigationIds = emailResults
+ .filter(result => result.success)
+ .map(result => result.investigationId)
+
+ if (successfulInvestigationIds.length > 0) {
+ await tx
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "RESULT_SENT",
+ purchaseComment: input.purchaseComment,
+ updatedAt: new Date(),
+ })
+ .where(inArray(vendorInvestigations.id, successfulInvestigationIds))
+ }
+
+ return {
+ totalCount: investigations.length,
+ successCount: emailResults.filter(r => r.success).length,
+ failedCount: emailResults.filter(r => !r.success).length,
+ emailResults,
+ }
+ })
+
+ // 캐시 무효화
+ revalidateTag("vendor-investigations")
+ revalidateTag("pq-submissions")
+
+ return {
+ success: true,
+ data: result,
+ message: `${result.successCount}개 실사 결과가 성공적으로 발송되었습니다.`
+ }
+ } catch (err) {
+ console.error("실사 결과 발송 중 오류 발생:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."
+ }
+ }
+}
+
+// 실사 방법 라벨 변환 함수
+function getInvestigationMethodLabel(method: string): string {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method
+ }
+}
+
+export async function getQMManagers() {
+ try {
+ // QM 부서 사용자만 필터링 (department 필드가 있다고 가정)
+ // 또는 QM 역할을 가진 사용자만 필터링 (role 필드가 있다고 가정)
+ const qmUsers = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ })
+ .from(users)
+ // .where(
+ // // 필요에 따라 조건 조정 (예: QM 부서 또는 특정 역할만)
+ // // eq(users.department, "QM") 또는
+ // // eq(users.role, "QM_MANAGER")
+ // // 테스트를 위해 모든 사용자 반환도 가능
+ // eq(users.active, true)
+ // )
+ .orderBy(users.name)
+
+ return {
+ data: qmUsers,
+ success: true
+ }
+ } catch (error) {
+ console.error("QM 담당자 목록 조회 오류:", error)
+ return {
+ data: [],
+ success: false,
+ error: error instanceof Error ? error.message : "QM 담당자 목록을 가져오는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+export async function getFactoryLocationAnswer(vendorId: number, projectId: number | null = null) {
+ try {
+ // 1. "Location of Factory" 체크포인트를 가진 criteria 찾기
+ const criteria = await db
+ .select({
+ id: pqCriterias.id
+ })
+ .from(pqCriterias)
+ .where(ilike(pqCriterias.checkPoint, "%Location of Factory%"))
+ .limit(1);
+
+ if (!criteria.length) {
+ return { success: false, message: "Factory Location 질문을 찾을 수 없습니다." };
+ }
+
+ const criteriaId = criteria[0].id;
+
+ // 2. 해당 criteria에 대한 벤더의 응답 조회
+ const answerQuery = db
+ .select({
+ answer: vendorPqCriteriaAnswers.answer
+ })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.criteriaId, criteriaId)
+ )
+ );
+
+ // 프로젝트 ID가 있으면 추가 조건
+ if (projectId !== null) {
+ answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else {
+ answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, null));
+ }
+
+ const answers = await answerQuery.limit(1);
+
+ if (!answers.length || !answers[0].answer) {
+ return { success: false, message: "공장 위치 정보를 찾을 수 없습니다." };
+ }
+
+ return {
+ success: true,
+ factoryLocation: answers[0].answer
+ };
+ } catch (error) {
+ console.error("Factory location 조회 오류:", error);
+ return { success: false, message: "오류가 발생했습니다." };
+ }
+}
+
+// -----------------------------------------------------------------------------
+// PQ LISTS (GENERAL / PROJECT / NON_INSPECTION) CRUD + 조회
+
+// LOAD CRITERIAS BY LIST
+export async function getPqCriteriasByListId(listId: number) {
+ const criterias = await db
+ .select()
+ .from(pqCriterias)
+ .where(eq(pqCriterias.pqListId, listId))
+ .orderBy(pqCriterias.groupName, pqCriterias.code);
+ return criterias;
+}
+
+// -----------------------------------------------------------------------------
+// PQ LISTS CRUD 액션 - 개선된 버전
+// -----------------------------------------------------------------------------
+
+
+export async function getPQLists(input: GetPqListsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(pqLists.name, s),
+ ilike(pqLists.type, s)
+ );
+ }
+
+ const advancedWhere = input.filters
+ ? filterColumns({ table: pqLists, filters: input.filters, joinOperator: input.joinOperator })
+ : undefined;
+
+ const finalWhere = and(
+ // eq(pqLists.isDeleted, false),
+ advancedWhere,
+ globalWhere
+ );
+
+ const orderBy = input.sort.length
+ ? input.sort.map((s) => (s.desc ? desc(pqLists.createdAt) : asc(pqLists.createdAt)))
+ : [desc(pqLists.createdAt)];
+
+ const { data, total } = await db.transaction(async (tx) => {
+ // 만료된 PQ 리스트들을 자동으로 비활성화
+ const now = new Date();
+ await tx
+ .update(pqLists)
+ .set({
+ isDeleted: true,
+ updatedAt: now
+ })
+ .where(
+ and(
+ eq(pqLists.isDeleted, false),
+ lt(pqLists.validTo, now),
+ isNotNull(pqLists.validTo)
+ )
+ );
+
+ const data = await tx
+ .select({
+ id: pqLists.id,
+ name: pqLists.name,
+ type: pqLists.type,
+ projectId: pqLists.projectId,
+ validTo: pqLists.validTo,
+ isDeleted: pqLists.isDeleted,
+ createdAt: pqLists.createdAt,
+ updatedAt: pqLists.updatedAt,
+ createdBy: users.name,
+ projectCode: projects.code,
+ projectName: projects.name,
+ updatedBy: users.name,
+ })
+ .from(pqLists)
+ .leftJoin(projects, eq(pqLists.projectId, projects.id))
+ .leftJoin(users, eq(pqLists.createdBy, users.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage);
+
+ const countRes = await tx
+ .select({ count: count() })
+ .from(pqLists)
+ .where(finalWhere);
+
+ // 각 PQ 리스트의 항목 수 조회
+ const dataWithCriteriaCount = await Promise.all(
+ data.map(async (item) => {
+ const criteriaCount = await getPqListCriteriaCount(item.id);
+ return {
+ ...item,
+ criteriaCount
+ };
+ })
+ );
+
+
+ return { data: dataWithCriteriaCount, total: countRes[0]?.count ?? 0 };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ console.error("Error in getPQLists:", err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ { revalidate: 3600, tags: ["pq-lists"] }
+ )();
+}
+
+export async function togglePQListsAction(ids: number[], newIsDeleted: boolean) {
+ try {
+ if (ids.length === 0) {
+ return { success: false, message: "선택한 항목이 없습니다" };
+ }
+ const session = await getServerSession(authOptions);
+ const userId = session?.user?.id ? Number(session.user.id) : null;
+ const now = new Date();
+ const updated = await db
+ .update(pqLists)
+ .set({ isDeleted: newIsDeleted, updatedAt: now, updatedBy: userId })
+ .where(inArray(pqLists.id, ids))
+ .returning();
+ revalidateTag("pq-lists");
+ return {
+ success: true,
+ data: updated,
+ message: `${updated.length}개의 PQ 목록이 ${newIsDeleted ? "비활성화" : "활성화"}되었습니다`
+ };
+ } catch (error) {
+ console.error("Error toggling PQ lists:", error);
+ return {
+ success: false,
+ message: "PQ 목록 상태 변경에 실패했습니다"
+ };
+ }
+}
+
+export async function createPQListAction(input: CreatePqListInput) {
+ try {
+ const validated = createPqListSchema.parse(input);
+ const session = await getServerSession(authOptions);
+ const userId = session?.user?.id;
+ // const userName = session?.user?.name || "Unknown";
+
+ // General PQ인 경우 중복 체크
+ if (validated.type === "GENERAL") {
+ const existingGeneralPQ = await db
+ .select()
+ .from(pqLists)
+ .where(
+ and(
+ eq(pqLists.type, "GENERAL"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .limit(1);
+
+ if (existingGeneralPQ.length > 0) {
+ return {
+ success: false,
+ error: "General PQ 목록은 하나만 생성할 수 있습니다"
+ };
+ }
+ }
+
+ // 프로젝트 PQ인 경우 중복 체크
+ if (validated.type === "PROJECT" && validated.projectId) {
+ const existingPQ = await db
+ .select()
+ .from(pqLists)
+ .where(
+ and(
+ eq(pqLists.projectId, validated.projectId),
+ eq(pqLists.type, "PROJECT"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .limit(1);
+
+ if (existingPQ.length > 0) {
+ return {
+ success: false,
+ error: "해당 프로젝트에 대한 PQ가 이미 존재합니다"
+ };
+ }
+ }
+ return await db.transaction(async (tx) => {
+ const now = new Date();
+ const [newPqList] = await tx
+ .insert(pqLists)
+ .values({
+ ...validated,
+ isDeleted: false,
+ createdAt: now,
+ updatedAt: now,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+ .returning();
+
+ // 프로젝트 PQ인 경우 General PQ 항목들을 자동으로 복사
+ let copiedCriteriaCount = 0;
+ if (validated.type === "PROJECT") {
+ // General PQ 목록 찾기
+ const generalPqList = await tx
+ .select()
+ .from(pqLists)
+ .where(
+ and(
+ eq(pqLists.type, "GENERAL"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (generalPqList) {
+ // General PQ의 항목들 조회
+ const generalCriterias = await tx
+ .select()
+ .from(pqCriterias)
+ .where(eq(pqCriterias.pqListId, generalPqList.id));
+
+ if (generalCriterias.length > 0) {
+ // 새로운 프로젝트 PQ에 항목들 복사
+ const newCriterias = generalCriterias.map(criteria => ({
+ code: criteria.code,
+ checkPoint: criteria.checkPoint,
+ description: criteria.description,
+ remarks: criteria.remarks,
+ groupName: criteria.groupName,
+ subGroupName: criteria.subGroupName,
+ pqListId: newPqList.id,
+ inputFormat: criteria.inputFormat,
+ createdAt: now,
+ updatedAt: now,
+ }));
+
+ await tx.insert(pqCriterias).values(newCriterias);
+ copiedCriteriaCount = newCriterias.length;
+ }
+ }
+ }
+
+ revalidateTag("pq-lists");
+ revalidateTag("pq-criterias");
+ return {
+ success: true,
+ data: newPqList,
+ copiedCriteriaCount
+ };
+ });
+ } catch (error) {
+ console.error("Error creating PQ list:", error);
+
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "유효성 검사 실패",
+ details: error.errors
+ };
+ }
+
+ return {
+ success: false,
+ error: "PQ 목록 생성에 실패했습니다"
+ };
+ }
+}
+export async function deletePQListsAction(ids: number[]) {
+ try {
+ if (ids.length === 0) {
+ return {
+ success: false,
+ message: "삭제할 항목을 선택해주세요"
+ };
+ }
+ console.log("ids", ids)
+ console.log("pqLists", pqLists)
+ const now = new Date();
+ const updated = await db
+ .update(pqLists)
+ .set({ isDeleted: true, updatedAt: now })
+ .where(inArray(pqLists.id, ids))
+ .returning();
+
+ revalidateTag("pq-lists");
+ return {
+ success: true,
+ data: updated,
+ message: `${updated.length}개의 PQ 목록이 비활성화되었습니다`
+ };
+ } catch (error) {
+ console.error("Error deleting PQ lists:", error);
+ return {
+ success: false,
+ message: "PQ 목록 삭제에 실패했습니다"
+ };
+ }
+}
+
+export async function getPqListById(id: number) {
+ try {
+ const pqList = await db
+ .select()
+ .from(pqLists)
+ .where(and(
+ eq(pqLists.id, id),
+ eq(pqLists.isDeleted, false)
+ ))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ return pqList || null;
+ } catch (error) {
+ console.error("Error fetching PQ list by ID:", error);
+ return null;
+ }
+}
+
+export async function getPqListCriteriaCount(listId: number) {
+ try {
+ const result = await db
+ .select({ count: count() })
+ .from(pqCriterias)
+ .where(eq(pqCriterias.pqListId, listId));
+
+ return result[0]?.count || 0;
+ } catch (error) {
+ console.error("Error getting PQ list criteria count:", error);
+ return 0;
+ }
+}
+
+
+
+export async function copyPQListAction(input: CopyPqListInput) {
+ try {
+ const validated = copyPqListSchema.parse(input);
+ const session = await getServerSession(authOptions);
+ const userId = session?.user?.id;
+ return await db.transaction(async (tx) => {
+ // 1. 원본 PQ 목록 조회
+ const sourcePqList = await tx
+ .select()
+ .from(pqLists)
+ .where(eq(pqLists.id, validated.sourcePqListId))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!sourcePqList) {
+ return {
+ success: false,
+ error: "복사할 PQ 목록을 찾을 수 없습니다"
+ };
+ }
+
+ // 2. 대상 프로젝트에 이미 PQ가 존재하는지 확인
+ const existingProjectPQ = await tx
+ .select()
+ .from(pqLists)
+ .where(
+ and(
+ eq(pqLists.projectId, validated.targetProjectId),
+ eq(pqLists.type, "PROJECT"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .limit(1);
+
+ if (existingProjectPQ.length > 0) {
+ return {
+ success: false,
+ error: "해당 프로젝트에 대한 PQ가 이미 존재합니다"
+ };
+ }
+
+ // 3. 새 PQ 목록 생성
+ const now = new Date();
+ const newName = validated.newName || `${sourcePqList.name} (복사본)`;
+
+ const [newPqList] = await tx
+ .insert(pqLists)
+ .values({
+ name: newName || sourcePqList.name,
+ type: sourcePqList.type,
+ projectId: validated.targetProjectId,
+ isDeleted: false,
+ createdAt: now,
+ updatedAt: now,
+ createdBy: userId,
+ updatedBy: userId,
+ validTo: validated.validTo,
+ })
+ .returning();
+
+ // 4. 원본 PQ 항목들 조회 및 복사
+ const sourceCriterias = await tx
+ .select()
+ .from(pqCriterias)
+ .where(eq(pqCriterias.pqListId, validated.sourcePqListId));
+
+ if (sourceCriterias.length > 0) {
+ const newCriterias = sourceCriterias.map(criteria => ({
+ code: criteria.code,
+ checkPoint: criteria.checkPoint,
+ description: criteria.description,
+ remarks: criteria.remarks,
+ groupName: criteria.groupName,
+ subGroupName: criteria.subGroupName,
+ pqListId: newPqList.id,
+ inputFormat: criteria.inputFormat,
+ createdAt: now,
+ updatedAt: now,
+ createdBy: userId,
+ updatedBy: userId,
+ }));
+
+ await tx.insert(pqCriterias).values(newCriterias);
+ }
+
+ revalidateTag("pq-lists");
+ return {
+ success: true,
+ data: newPqList,
+ copiedCriteriaCount: sourceCriterias.length
+ };
+ });
+ } catch (error) {
+ console.error("Error copying PQ list:", error);
+
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "유효성 검사 실패",
+ details: error.errors
+ };
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PQ 목록 복사에 실패했습니다"
+ };
+ }
+}
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ pjtType: string;
+}
+// -----------------------------------------------------------------------------
+export async function getProjects() {
+ try {
+ const projectList = await db.transaction(async (tx) => {
+ const results = await tx
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ type: projects.type,
+ createdAt: projects.createdAt,
+ updatedAt: projects.updatedAt,
+ })
+ .from(projects)
+ .orderBy(projects.code);
+
+ return results;
+ });
+
+ return projectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return [];
+ }
+}
+
+// PQ 리스트에 등재된 프로젝트만 가져오는 함수
+export async function getProjectsWithPQList() {
+ try {
+ const projectList = await db.transaction(async (tx) => {
+ const results = await tx
+ .select({
+ id: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ pjtType: projects.type,
+ type: projects.type,
+ createdAt: projects.createdAt,
+ updatedAt: projects.updatedAt,
+ })
+ .from(projects)
+ .innerJoin(pqLists, eq(projects.id, pqLists.projectId))
+ .where(
+ and(
+ eq(pqLists.type, "PROJECT"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .orderBy(projects.code);
+
+ return results;
+ });
+
+ return projectList;
+ } catch (error) {
+ console.error("PQ 리스트 등재 프로젝트 목록 가져오기 실패:", error);
+ return [];
+ }
+}
+// -----------------------------------------------------------------------------
+// PQ Criteria CRUD 액션 - 개선된 버전
+// -----------------------------------------------------------------------------
+
+// PQ 항목 생성 (특정 PQ 목록에 속함)
+export async function createPqCriteria(
+ pqListId: number,
+ input: {
+ code: string;
+ checkPoint: string;
+ groupName: string;
+ subGroupName?: string;
+ description?: string;
+ remarks?: string;
+ inputFormat?: string;
+ }
+) {
+ try {
+ const now = new Date();
+ const [newCriteria] = await db
+ .insert(pqCriterias)
+ .values({
+ code: input.code,
+ checkPoint: input.checkPoint,
+ description: input.description || null,
+ remarks: input.remarks || null,
+ groupName: input.groupName,
+ subGroupName: input.subGroupName || null,
+ pqListId: pqListId,
+ inputFormat: input.inputFormat || "TEXT",
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ revalidateTag("pq-criterias");
+ return {
+ success: true,
+ data: newCriteria,
+ message: "PQ 항목이 성공적으로 생성되었습니다"
+ };
+ } catch (error) {
+ console.error("Error creating PQ criteria:", error);
+ return {
+ success: false,
+ message: "PQ 항목 생성에 실패했습니다"
+ };
+ }
+}
+
+// PQ 항목 수정
+export async function updatePqCriteria(
+ id: number,
+ input: {
+ code: string;
+ checkPoint: string;
+ groupName: string;
+ subGroupName?: string;
+ description?: string;
+ remarks?: string;
+ inputFormat?: string;
+ }
+) {
+ try {
+ const now = new Date();
+ const [updatedCriteria] = await db
+ .update(pqCriterias)
+ .set({
+ code: input.code,
+ checkPoint: input.checkPoint,
+ description: input.description || null,
+ remarks: input.remarks || null,
+ groupName: input.groupName,
+ subGroupName: input.subGroupName || null,
+ inputFormat: input.inputFormat || "TEXT",
+ updatedAt: now,
+ })
+ .where(eq(pqCriterias.id, id))
+ .returning();
+
+ if (!updatedCriteria) {
+ return {
+ success: false,
+ message: "수정할 PQ 항목을 찾을 수 없습니다"
+ };
+ }
+
+ revalidateTag("pq-criterias");
+ return {
+ success: true,
+ data: updatedCriteria,
+ message: "PQ 항목이 성공적으로 수정되었습니다"
+ };
+ } catch (error) {
+ console.error("Error updating PQ criteria:", error);
+ return {
+ success: false,
+ message: "PQ 항목 수정에 실패했습니다"
+ };
+ }
+}
+
+// PQ 항목 삭제
+export async function deletePqCriterias(ids: number[]) {
+ try {
+ if (ids.length === 0) {
+ return {
+ success: false,
+ message: "삭제할 항목을 선택해주세요"
+ };
+ }
+
+ const deletedCriterias = await db
+ .delete(pqCriterias)
+ .where(inArray(pqCriterias.id, ids))
+ .returning();
+
+ revalidateTag("pq-criterias");
+ return {
+ success: true,
+ data: deletedCriterias,
+ message: `${deletedCriterias.length}개의 PQ 항목이 삭제되었습니다`
+ };
+ } catch (error) {
+ console.error("Error deleting PQ criterias:", error);
+ return {
+ success: false,
+ message: "PQ 항목 삭제에 실패했습니다"
+ };
+ }
+}
+
+/**
+ * PQ 제출 삭제 함수 (REQUESTED 상태일 때만 삭제 가능)
+ */
+export async function deletePQSubmissionAction(pqSubmissionId: number) {
+ try {
+ // PQ 제출 정보 조회
+ const submission = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId))
+ .limit(1);
+
+ if (submission.length === 0) {
+ return { success: false, error: "PQ 제출을 찾을 수 없습니다." };
+ }
+
+ const pqSubmission = submission[0];
+
+ // REQUESTED 상태가 아니면 삭제 불가
+ if (pqSubmission.status !== "REQUESTED") {
+ return { success: false, error: "요청됨 상태가 아닌 PQ는 삭제할 수 없습니다." };
+ }
+
+ // 트랜잭션으로 관련 데이터 모두 삭제
+ await db.transaction(async (tx) => {
+ // 1. PQ 답변 삭제 (vendorId와 projectId로 식별)
+ await tx
+ .delete(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, pqSubmission.vendorId),
+ pqSubmission.projectId
+ ? eq(vendorPqCriteriaAnswers.projectId, pqSubmission.projectId)
+ : isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ );
+
+ // 2. 첨부파일 삭제 (vendorCriteriaAnswerId로 연결)
+ const answerIds = await tx
+ .select({ id: vendorPqCriteriaAnswers.id })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, pqSubmission.vendorId),
+ pqSubmission.projectId
+ ? eq(vendorPqCriteriaAnswers.projectId, pqSubmission.projectId)
+ : isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ );
+
+ if (answerIds.length > 0) {
+ await tx
+ .delete(vendorCriteriaAttachments)
+ .where(inArray(vendorCriteriaAttachments.vendorCriteriaAnswerId, answerIds.map(a => a.id)));
+ }
+
+ // 3. PQ 제출 삭제
+ await tx
+ .delete(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error("deletePQSubmissionAction error:", error);
+ return { success: false, error: String(error) };
+ }
+}
+
+// PQ 목록별 항목 조회 (특정 pqListId에 속한 PQ 항목들)
+export async function getPQsByListId(pqListId: number, input: GetPQSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(pqCriterias.code, s),
+ ilike(pqCriterias.groupName, s),
+ ilike(pqCriterias.subGroupName, s),
+ ilike(pqCriterias.remarks, s),
+ ilike(pqCriterias.checkPoint, s),
+ ilike(pqCriterias.description, s)
+ );
+ }
+
+ // 고급 필터
+ const advancedWhere = input.filters
+ ? filterColumns({ table: pqCriterias, filters: input.filters, joinOperator: input.joinOperator })
+ : undefined;
+
+ // 최종 WHERE 조건 (pqListId 조건 추가)
+ const finalWhere = and(
+ eq(pqCriterias.pqListId, pqListId), // 특정 PQ 목록에 속한 항목들만
+ advancedWhere,
+ globalWhere
+ );
+
+ // 정렬
+ const orderBy = input.sort.length
+ ? input.sort.map((s) => (s.desc ? desc(pqCriterias[s.id]) : asc(pqCriterias[s.id])))
+ : [asc(pqCriterias.createdAt)];
+
+ const { data, total } = await db.transaction(async (tx) => {
+ // 데이터 조회
+ const data = await tx
+ .select({
+ id: pqCriterias.id,
+ code: pqCriterias.code,
+ checkPoint: pqCriterias.checkPoint,
+ description: pqCriterias.description,
+ remarks: pqCriterias.remarks,
+ groupName: pqCriterias.groupName,
+ subGroupName: pqCriterias.subGroupName,
+ pqListId: pqCriterias.pqListId,
+ inputFormat: pqCriterias.inputFormat,
+
+ createdAt: pqCriterias.createdAt,
+ updatedAt: pqCriterias.updatedAt,
+ })
+ .from(pqCriterias)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage);
+
+ // 카운트 조회
+ const countRes = await tx
+ .select({ count: count() })
+ .from(pqCriterias)
+ .where(finalWhere);
+
+ const total = countRes[0]?.count ?? 0;
+
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ console.error("Error in getPQsByListId:", err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), pqListId.toString()],
+ { revalidate: 3600, tags: ["pq-criterias"] }
+ )();
+}
+
+// 실사 정보 업데이트 액션 (구매자체평가용)
+export async function updateInvestigationDetailsAction(input: {
+ investigationId: number;
+ confirmedAt?: Date;
+ evaluationResult?: "APPROVED" | "SUPPLEMENT" | "REJECTED";
+ investigationNotes?: string;
+}) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date(),
+ };
+
+ if (input.confirmedAt !== undefined) {
+ updateData.confirmedAt = input.confirmedAt;
+ }
+
+ if (input.evaluationResult !== undefined) {
+ updateData.evaluationResult = input.evaluationResult;
+ }
+
+ if (input.investigationNotes !== undefined) {
+ updateData.investigationNotes = input.investigationNotes;
+ }
+
+ await db
+ .update(vendorInvestigations)
+ .set(updateData)
+ .where(eq(vendorInvestigations.id, input.investigationId));
+
+ revalidateTag("pq-submissions");
+ revalidatePath("/evcp/pq_new");
+
+ return {
+ success: true,
+ message: "실사 정보가 성공적으로 업데이트되었습니다."
+ };
+
+ } catch (error) {
+ console.error("실사 정보 업데이트 오류:", error);
+ return {
+ success: false,
+ error: "실사 정보 업데이트 중 오류가 발생했습니다."
+ };
+ }
+}
+
+export async function autoDeactivateExpiredPQLists() {
+ try {
+ const now = new Date();
+
+ // 유효기간이 지난 PQ 리스트들을 비활성화
+ const expiredLists = await db
+ .update(pqLists)
+ .set({
+ isDeleted: true,
+ updatedAt: now
+ })
+ .where(
+ and(
+ eq(pqLists.isDeleted, false),
+ lt(pqLists.validTo, now),
+ isNotNull(pqLists.validTo)
+ )
+ )
+ .returning();
+
+ console.log(`[PQ Auto Deactivation] ${expiredLists.length}개의 만료된 PQ 리스트가 비활성화되었습니다.`);
+
+ if (expiredLists.length > 0) {
+ revalidateTag("pq-lists");
+ }
+
+ return {
+ success: true,
+ deactivatedCount: expiredLists.length,
+ message: `${expiredLists.length}개의 만료된 PQ 리스트가 비활성화되었습니다.`
+ };
+ } catch (error) {
+ console.error("Error auto-deactivating expired PQ lists:", error);
+ return {
+ success: false,
+ message: "만료된 PQ 리스트 자동 비활성화에 실패했습니다."
+ };
+ }
+}
+
+// SHI 참석자 총 인원수 계산 함수
+
+export async function getTotalShiAttendees(shiAttendees: Record<string, unknown> | null): Promise<number> {
+ if (!shiAttendees) return 0
+
+ let total = 0
+ Object.entries(shiAttendees).forEach(([key, value]) => {
+ if (value && typeof value === 'object' && 'checked' in value && 'count' in value) {
+ const attendee = value as { checked: boolean; count: number }
+ if (attendee.checked) {
+ total += attendee.count
+ }
+ }
+ })
+ return total
} \ No newline at end of file
diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx
deleted file mode 100644
index 2fac9e43..00000000
--- a/lib/pq/table/add-pq-dialog.tsx
+++ /dev/null
@@ -1,454 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Plus } from "lucide-react"
-import { useRouter } from "next/navigation"
-
-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 { Textarea } from "@/components/ui/textarea"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Checkbox } from "@/components/ui/checkbox"
-import { useToast } from "@/hooks/use-toast"
-import { createPq, invalidatePqCache } from "../service"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import { type Project } from "@/lib/rfqs/service"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-// PQ 생성을 위한 Zod 스키마 정의
-const createPqSchema = z.object({
- code: z.string().min(1, "Code is required"),
- checkPoint: z.string().min(1, "Check point is required"),
- groupName: z.string().min(1, "Group is required"),
- description: z.string().optional(),
- remarks: z.string().optional(),
- // 프로젝트별 PQ 여부 체크박스
- isProjectSpecific: z.boolean().default(false),
- // 프로젝트 관련 추가 필드는 isProjectSpecific가 true일 때만 필수
- contractInfo: z.string().optional(),
- additionalRequirement: z.string().optional(),
-});
-
-type CreatePqFormType = z.infer<typeof createPqSchema>;
-
-// 그룹 이름 옵션
-export const groupOptions = [
- "GENERAL",
- "Quality Management System",
- "Workshop & Environment",
- "Warranty",
-];
-
-// 설명 예시 텍스트
-const descriptionExample = `Address :
-Tel. / Fax :
-e-mail :`;
-
-interface AddPqDialogProps {
- currentProjectId?: number | null; // 현재 선택된 프로젝트 ID (옵션)
-}
-
-export function AddPqDialog({ currentProjectId }: AddPqDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
- const router = useRouter()
- const { toast } = useToast()
-
- // react-hook-form 설정
- const form = useForm<CreatePqFormType>({
- resolver: zodResolver(createPqSchema),
- defaultValues: {
- code: "",
- checkPoint: "",
- groupName: groupOptions[0],
- description: "",
- remarks: "",
- isProjectSpecific: !!currentProjectId, // 현재 프로젝트 ID가 있으면 기본값 true
- contractInfo: "",
- additionalRequirement: "",
- },
- })
-
- // 프로젝트별 PQ 여부 상태 감시
- const isProjectSpecific = form.watch("isProjectSpecific")
-
- // 현재 프로젝트 ID가 있으면 선택된 프로젝트 설정
- React.useEffect(() => {
- if (currentProjectId) {
- form.setValue("isProjectSpecific", true)
- }
- }, [currentProjectId, form])
-
- // 예시 텍스트를 description 필드에 채우는 함수
- const fillExampleText = () => {
- form.setValue("description", descriptionExample);
- };
-
- async function onSubmit(data: CreatePqFormType) {
- try {
- setIsSubmitting(true)
-
- // 서버 액션 호출용 데이터 준비
- const submitData = {
- ...data,
- projectId: data.isProjectSpecific ? selectedProject?.id || currentProjectId : null,
- }
-
- // 프로젝트별 PQ인데 프로젝트가 선택되지 않은 경우 검증
- if (data.isProjectSpecific && !submitData.projectId) {
- toast({
- title: "Error",
- description: "Please select a project",
- variant: "destructive",
- })
- setIsSubmitting(false)
- return
- }
-
- // 서버 액션 호출
- const result = await createPq(submitData)
-
- if (!result.success) {
- toast({
- title: "Error",
- description: result.message || "Failed to create PQ criteria",
- variant: "destructive",
- })
- return
- }
-
- await invalidatePqCache();
-
- // 성공 시 처리
- toast({
- title: "Success",
- description: result.message || "PQ criteria created successfully",
- })
-
- // 모달 닫고 폼 리셋
- form.reset()
- setSelectedProject(null)
- setOpen(false)
-
- // 페이지 새로고침
- router.refresh()
-
- } catch (error) {
- console.error('Error creating PQ criteria:', error)
- toast({
- title: "Error",
- description: "An unexpected error occurred",
- variant: "destructive",
- })
- } finally {
- setIsSubmitting(false)
- }
- }
-
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- form.reset()
- setSelectedProject(null)
- }
- setOpen(nextOpen)
- }
-
- // 프로젝트 선택 핸들러
- const handleProjectSelect = (project: Project | null) => {
- // project가 null인 경우 선택 해제를 의미
- if (project === null) {
- setSelectedProject(null);
- // 필요한 경우 추가 처리
- return;
- }
-
- // 기존 처리 - 프로젝트가 선택된 경우
- setSelectedProject(project);
- }
-
- return (
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- {/* 모달을 열기 위한 버튼 */}
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- <Plus className="size-4" />
- Add PQ
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle>Create New PQ Criteria</DialogTitle>
- <DialogDescription>
- 새 PQ 기준 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
- </DialogDescription>
- </DialogHeader>
-
- {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2 flex flex-col">
- {/* 프로젝트별 PQ 여부 체크박스 */}
-
- <div className="flex-1 overflow-auto px-4 space-y-4">
- <FormField
- control={form.control}
- name="isProjectSpecific"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel>프로젝트별 PQ 생성</FormLabel>
- <FormDescription>
- 특정 프로젝트에만 적용되는 PQ 항목을 생성합니다
- </FormDescription>
- </div>
- </FormItem>
- )}
- />
-
- {/* 프로젝트 선택 필드 (프로젝트별 PQ 선택 시에만 표시) */}
- {isProjectSpecific && (
- <div className="space-y-2">
- <FormLabel>Project <span className="text-destructive">*</span></FormLabel>
- <ProjectSelector
- selectedProjectId={currentProjectId || selectedProject?.id}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트를 선택하세요"
- />
- <FormDescription>
- PQ 항목을 적용할 프로젝트를 선택하세요
- </FormDescription>
- </div>
- )}
-
- <div className="flex-1 overflow-auto px-2 py-2 space-y-4" style={{maxHeight:420}}>
-
-
- {/* Code 필드 */}
- <FormField
- control={form.control}
- name="code"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
- <FormControl>
- <Input
- placeholder="예: 1-1, A.2.3"
- {...field}
- />
- </FormControl>
- <FormDescription>
- PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3")
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Check Point 필드 */}
- <FormField
- control={form.control}
- name="checkPoint"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
- <FormControl>
- <Input
- placeholder="검증 항목을 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Group Name 필드 (Select) */}
- <FormField
- control={form.control}
- name="groupName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- value={field.value}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="그룹을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {groupOptions.map((group) => (
- <SelectItem key={group} value={group}>
- {group}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormDescription>
- PQ 항목의 분류 그룹을 선택하세요
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Description 필드 - 예시 템플릿 버튼 추가 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <div className="flex items-center justify-between">
- <FormLabel>Description</FormLabel>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={fillExampleText}
- >
- 예시 채우기
- </Button>
- </div>
- <FormControl>
- <Textarea
- placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`}
- className="min-h-[120px] font-mono"
- {...field}
- value={field.value || ""}
- />
- </FormControl>
- <FormDescription>
- 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Remarks 필드 */}
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Remarks</FormLabel>
- <FormControl>
- <Textarea
- placeholder="비고 사항을 입력하세요"
- className="min-h-[80px]"
- {...field}
- value={field.value || ""}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트별 PQ일 경우 추가 필드 */}
- {isProjectSpecific && (
- <>
- {/* 계약 정보 필드 */}
- <FormField
- control={form.control}
- name="contractInfo"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Contract Info</FormLabel>
- <FormControl>
- <Textarea
- placeholder="계약 관련 정보를 입력하세요"
- className="min-h-[80px]"
- {...field}
- value={field.value || ""}
- />
- </FormControl>
- <FormDescription>
- 해당 프로젝트의 계약 관련 특이사항
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 추가 요구사항 필드 */}
- <FormField
- control={form.control}
- name="additionalRequirement"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Additional Requirements</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 요구사항을 입력하세요"
- className="min-h-[80px]"
- {...field}
- value={field.value || ""}
- />
- </FormControl>
- <FormDescription>
- 프로젝트별 추가 요구사항
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- </>
- )}
- </div>
-
- </div>
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => {
- form.reset();
- setSelectedProject(null);
- setOpen(false);
- }}
- >
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={isSubmitting}
- >
- {isSubmitting ? "Creating..." : "Create"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/pq/table/add-pq-list-dialog.tsx b/lib/pq/table/add-pq-list-dialog.tsx
new file mode 100644
index 00000000..c1899a29
--- /dev/null
+++ b/lib/pq/table/add-pq-list-dialog.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Loader2, Plus } from "lucide-react"
+
+// 프로젝트 목록을 위한 임시 타입 (실제로는 projects에서 가져와야 함)
+interface Project {
+ id: number
+ name: string
+ code: string
+}
+
+const pqListFormSchema = z.object({
+ name: z.string().min(1, "PQ 목록 명을 입력해주세요"),
+ type: z.enum(["GENERAL", "PROJECT", "NON_INSPECTION"], {
+ required_error: "PQ 유형을 선택해주세요"
+ }),
+ projectId: z.number().optional().nullable(),
+ validTo: z.date().optional().nullable(),
+}).refine((data) => {
+ // 프로젝트 PQ인 경우 프로젝트 선택 필수
+ if (data.type === "PROJECT" && !data.projectId) {
+ return false
+ }
+ return true
+}, {
+ message: "프로젝트 PQ인 경우 프로젝트를 선택해야 합니다",
+ path: ["projectId"]
+})
+
+type PqListFormData = z.infer<typeof pqListFormSchema>
+
+interface PqListFormProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ initialData?: Partial<PqListFormData> & { id?: number }
+ projects?: Project[]
+ onSubmit: (data: PqListFormData & { id?: number }) => Promise<void>
+ isLoading?: boolean
+}
+
+const typeLabels = {
+ GENERAL: "일반 PQ",
+ PROJECT: "프로젝트 PQ",
+ NON_INSPECTION: "미실사 PQ"
+}
+
+export function AddPqDialog({
+ open,
+ onOpenChange,
+ initialData,
+ projects = [],
+ onSubmit,
+ isLoading = false
+}: PqListFormProps) {
+ const isEditing = !!initialData?.id
+
+ const form = useForm<PqListFormData>({
+ resolver: zodResolver(pqListFormSchema),
+ defaultValues: {
+ name: initialData?.name || "",
+ type: initialData?.type || "GENERAL",
+ projectId: initialData?.projectId || null,
+ validTo: initialData?.validTo || undefined,
+ }
+ })
+
+ const selectedType = form.watch("type")
+ const formState = form.formState
+
+ const handleSubmit = async (data: PqListFormData) => {
+ try {
+ await onSubmit({
+ ...data,
+ id: initialData?.id
+ })
+ form.reset()
+ onOpenChange(false)
+ } catch (error) {
+ // 에러는 상위 컴포넌트에서 처리
+ console.error("Failed to submit PQ list:", error)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Plus className="h-5 w-5" />
+ {isEditing ? "PQ 목록 수정" : "신규 PQ 목록 생성"}
+ </DialogTitle>
+ <DialogDescription>
+ {isEditing ? "PQ 목록 정보를 수정합니다." : "새로운 PQ 목록을 생성합니다."}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* PQ 유형 */}
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ PQ 유형 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="PQ 유형을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(typeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* PQ 목록 명 */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ PQ 리스트명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: General PQ v2.0, EPC-1234 Project PQ"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 (프로젝트 PQ인 경우만) */}
+ {selectedType === "PROJECT" && (
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 유효기간 (프로젝트 PQ인 경우) */}
+ {selectedType === "PROJECT" && (
+ <FormField
+ control={form.control}
+ name="validTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 유효일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value ?? undefined}
+ onSelect={(date) => field.onChange(date ?? null)}
+ placeholder="유효일 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 버튼들 */}
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading || !formState.isValid}>
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isEditing ? "수정" : "생성"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/table/copy-pq-list-dialog.tsx b/lib/pq/table/copy-pq-list-dialog.tsx
new file mode 100644
index 00000000..647ab1a3
--- /dev/null
+++ b/lib/pq/table/copy-pq-list-dialog.tsx
@@ -0,0 +1,244 @@
+"use client"
+
+// import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Loader2, Copy } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+// import { Card, CardContent } from "@/components/ui/card"
+
+interface PQList {
+ id: number
+ name: string
+ type: "GENERAL" | "PROJECT" | "NON_INSPECTION"
+ projectId?: number | null
+ criteriaCount?: number
+ createdAt: Date
+}
+
+interface Project {
+ id: number
+ name: string
+ code: string
+}
+
+const copyPqSchema = z.object({
+ sourcePqListId: z.number({
+ required_error: "복사할 PQ 목록을 선택해주세요"
+ }),
+ targetProjectId: z.number({
+ required_error: "대상 프로젝트를 선택해주세요"
+ }),
+ validTo: z.date(),
+ newName: z.string(),
+})
+
+type CopyPqFormData = z.infer<typeof copyPqSchema>
+
+interface CopyPqDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ pqLists: PQList[]
+ projects: Project[]
+ onCopy: (data: CopyPqFormData) => Promise<void>
+ isLoading?: boolean
+}
+
+const typeLabels = {
+ GENERAL: "일반 PQ",
+ PROJECT: "프로젝트 PQ",
+ NON_INSPECTION: "미실사 PQ"
+}
+
+const typeColors = {
+ GENERAL: "bg-blue-100 text-blue-800",
+ PROJECT: "bg-green-100 text-green-800",
+ NON_INSPECTION: "bg-orange-100 text-orange-800"
+}
+
+export function CopyPqDialog({
+ open,
+ onOpenChange,
+ pqLists,
+ projects,
+ onCopy,
+ isLoading = false
+}: CopyPqDialogProps) {
+ const form = useForm<CopyPqFormData>({
+ resolver: zodResolver(copyPqSchema),
+ })
+ const formState = form.formState
+
+ const selectedSourceId = form.watch("sourcePqListId")
+ const selectedPqList = pqLists.find(list => list.id === selectedSourceId)
+
+ const handleSubmit = async (data: CopyPqFormData) => {
+ try {
+ await onCopy(data)
+ form.reset()
+ onOpenChange(false)
+ } catch (error) {
+ // 에러는 상위 컴포넌트에서 처리
+ console.error("Failed to copy PQ list:", error)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Copy className="h-5 w-5" />
+ PQ 목록 불러오기
+ </DialogTitle>
+ <DialogDescription>
+ 기존 PQ 목록을 선택하여 새로운 프로젝트 PQ를 생성합니다.
+ 선택한 PQ의 모든 항목이 새 프로젝트로 복사됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 대상 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="targetProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 대상 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="PQ를 적용할 프로젝트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 복사할 PQ 목록 선택 */}
+ <FormField
+ control={form.control}
+ name="sourcePqListId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 복사할 PQ 리스트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="복사할 PQ 리스트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {pqLists.map((pqList) => (
+ <SelectItem key={pqList.id} value={pqList.id.toString()}>
+ <div className="flex items-center gap-2">
+ <Badge className={typeColors[pqList.type]}>
+ {typeLabels[pqList.type]}
+ </Badge>
+ <span>{pqList.name}</span>
+ {pqList.criteriaCount && (
+ <span className="text-xs text-muted-foreground">
+ ({pqList.criteriaCount}개 항목)
+ </span>
+ )}
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {selectedPqList && (
+ <div className="text-sm text-muted-foreground mt-1">
+ 선택된 PQ 리스트: <strong>{selectedPqList.name}</strong>
+ {selectedPqList.criteriaCount && (
+ <span> ({selectedPqList.criteriaCount}개 항목)</span>
+ )}
+ </div>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 새 PQ 목록 명 */}
+ <FormField
+ control={form.control}
+ name="newName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 새 PQ 리스트명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input {...field} value={field.value ?? ""} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 유효기간 설정 */}
+ <FormField
+ control={form.control}
+ name="validTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 유효기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value ?? undefined}
+ onSelect={(date) => field.onChange(date ?? null)}
+ placeholder="유효기간 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 버튼들 */}
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading || !formState.isValid}>
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 복사하여 생성
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/table/delete-pq-list-dialog.tsx b/lib/pq/table/delete-pq-list-dialog.tsx
new file mode 100644
index 00000000..c9a6eb09
--- /dev/null
+++ b/lib/pq/table/delete-pq-list-dialog.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import type { PQList } from "./pq-lists-columns"
+
+interface DeletePqListsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ pqLists: Row<PQList>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeletePqListsDialog({
+ pqLists,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeletePqListsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ // 상위 컴포넌트에서 삭제 로직을 처리하도록 콜백 호출
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({pqLists.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{pqLists.length}개</span>의 PQ 목록이 영구적으로 삭제됩니다.
+ <br />
+ <span className="text-red-600 font-medium">삭제 시 해당 PQ 목록의 모든 하위 항목들도 함께 삭제됩니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({pqLists.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{pqLists.length}개</span>의 PQ 목록이 영구적으로 삭제됩니다.
+ <br />
+ <span className="text-red-600 font-medium">삭제 시 해당 PQ 목록의 모든 하위 항목들도 함께 삭제됩니다.</span>
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/pq/table/pq-lists-columns.tsx b/lib/pq/table/pq-lists-columns.tsx
new file mode 100644
index 00000000..1c401fac
--- /dev/null
+++ b/lib/pq/table/pq-lists-columns.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+import { ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+// import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { DataTableRowAction } from "@/types/table"
+import { Ellipsis } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import React from "react"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Checkbox } from "@/components/ui/checkbox"
+
+export interface PQList {
+ id: number
+ name: string
+ type: "GENERAL" | "PROJECT" | "NON_INSPECTION"
+ projectId?: number | null
+ projectCode?: string | null
+ projectName?: string | null
+ isDeleted: boolean
+ validTo?: Date | null
+ createdBy?: string | null // 이제 사용자 이름(users.name)
+ createdAt: Date
+ updatedAt: Date
+ updatedBy?: string | null
+ criteriaCount?: number
+}
+
+const typeLabels = {
+ GENERAL: "일반 PQ",
+ PROJECT: "프로젝트 PQ",
+ NON_INSPECTION: "미실사 PQ"
+}
+
+const typeColors = {
+ GENERAL: "bg-blue-100 text-blue-800",
+ PROJECT: "bg-green-100 text-green-800",
+ NON_INSPECTION: "bg-orange-100 text-orange-800"
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQList> | null>>
+}
+export function createPQListsColumns({
+ setRowAction
+}: GetColumnsProps): ColumnDef<PQList>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "name",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 리스트명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate font-medium">
+ {row.getValue("name")}
+ </div>
+ ),
+ },
+ {
+ accessorKey: "type",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 종류" />
+ ),
+ cell: ({ row }) => {
+ const type = row.getValue("type") as keyof typeof typeLabels
+ return (
+ <Badge className={typeColors[type]}>
+ {typeLabels[type]}
+ </Badge>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ },
+ {
+ accessorKey: "projectCode",
+ header: "프로젝트",
+ cell: ({ row }) => row.original.projectCode ?? "-",
+ },
+ {
+ accessorKey: "projectName",
+ header: "프로젝트명",
+ cell: ({ row }) => row.original.projectName ?? "-",
+ },
+ {
+ accessorKey: "validTo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효일" />
+ ),
+ cell: ({ row }) => {
+ const validTo = row.getValue("validTo") as Date | null
+ const now = new Date()
+ const isExpired = validTo && validTo < now
+
+ const formattedDate = validTo ? formatDate(validTo, "ko-KR") : "-"
+
+ return (
+ <div className="text-sm">
+ <span className={isExpired ? "text-red-600 font-medium" : ""}>
+ {formattedDate}
+ </span>
+ {isExpired && (
+ <Badge variant="destructive" className="ml-2 text-xs">
+ 만료
+ </Badge>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "isDeleted",
+ header: "상태",
+ cell: ({ row }) => {
+ const isDeleted = row.getValue("isDeleted") as boolean;
+ return (
+ <Badge variant={isDeleted ? "destructive" : "success"}>
+ {isDeleted ? "비활성" : "활성"}
+ </Badge>
+ );
+ },
+ },
+ {
+ accessorKey: "createdBy",
+ header: "생성자",
+ cell: ({ row }) => row.original.createdBy ?? "-",
+ },
+ {
+ accessorKey: "updatedBy",
+ header: "변경자",
+ cell: ({ row }) => row.original.updatedBy ?? "-",
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => formatDate(row.getValue("createdAt"), "ko-KR"),
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="변경일" />
+ ),
+ cell: ({ row }) => formatDate(row.getValue("updatedAt"), "ko-KR"),
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "view" })}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ {/* <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ className="text-destructive"
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ ]
+} \ No newline at end of file
diff --git a/lib/pq/table/pq-lists-table.tsx b/lib/pq/table/pq-lists-table.tsx
new file mode 100644
index 00000000..c5fd82a5
--- /dev/null
+++ b/lib/pq/table/pq-lists-table.tsx
@@ -0,0 +1,170 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+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 { createPQListsColumns, type PQList } from "./pq-lists-columns"
+import {
+ createPQListAction,
+ deletePQListsAction,
+ copyPQListAction,
+ togglePQListsAction,
+} from "@/lib/pq/service"
+import { CopyPqDialog } from "./copy-pq-list-dialog"
+import { AddPqDialog } from "./add-pq-list-dialog"
+import { PQListsToolbarActions } from "./pq-lists-toolbar"
+import type { DataTableRowAction } from "@/types/table"
+
+interface Project {
+ id: number
+ name: string
+ code: string
+}
+
+interface PqListsTableProps {
+ promises: Promise<[{ data: PQList[]; pageCount: number }, Project[]]>
+}
+
+export function PqListsTable({ promises }: PqListsTableProps) {
+ const router = useRouter()
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQList> | null>(null)
+ const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
+ const [copyDialogOpen, setCopyDialogOpen] = React.useState(false)
+ const [isPending, startTransition] = React.useTransition()
+
+ const [{ data, pageCount }, projects] = React.use(promises)
+ const activePqLists = data.filter((item) => !item.isDeleted)
+
+ const columns = React.useMemo(() => createPQListsColumns({ setRowAction }), [setRowAction])
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: false,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCreate = async (formData: {
+ name: string
+ type: "GENERAL" | "PROJECT" | "NON_INSPECTION"
+ projectId?: number | null
+ validTo?: Date | null
+ }) => {
+ startTransition(async () => {
+ const result = await createPQListAction(formData)
+ if (result.success) {
+ toast.success("PQ 목록이 생성되었습니다")
+ setCreateDialogOpen(false)
+ router.refresh()
+ } else {
+ toast.error(result.error || "PQ 목록 생성 실패")
+ }
+ })
+ }
+
+ const handleToggleActive = async (ids: number[], newIsDeleted: boolean) => {
+ startTransition(async () => {
+ const result = await togglePQListsAction(ids, newIsDeleted)
+ if (result.success) {
+ toast.success(newIsDeleted ? "PQ 목록이 비활성화되었습니다" : "PQ 목록이 활성화되었습니다")
+ router.refresh()
+ } else {
+ toast.error("PQ 목록 상태 변경 실패")
+ }
+ })
+ }
+
+ const handleDelete = async (ids: number[]) => {
+ startTransition(async () => {
+ const result = await deletePQListsAction(ids)
+ if (result.success) {
+ toast.success("PQ 목록이 삭제되었습니다")
+ router.refresh()
+ } else {
+ toast.error("PQ 목록 삭제 실패")
+ }
+ })
+ }
+
+ const handleCopy = async (copyData: {
+ sourcePqListId: number
+ targetProjectId: number
+ newName?: string
+ validTo?: Date | null
+ }) => {
+ startTransition(async () => {
+ const result = await copyPQListAction(copyData)
+ if (result.success) {
+ toast.success("PQ 목록이 복사되었습니다")
+ setCopyDialogOpen(false)
+ router.refresh()
+ } else {
+ toast.error("PQ 목록 복사 실패")
+ }
+ })
+ }
+
+ React.useEffect(() => {
+ if (!rowAction) return
+ const id = rowAction.row.original.id
+ switch (rowAction.type) {
+ case "view":
+ router.push(`/evcp/pq-criteria/${id}`)
+ break
+ case "delete":
+ handleDelete([id])
+ break
+ }
+ setRowAction(null)
+ }, [rowAction])
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={[]}
+ shallow={false}
+ >
+ <PQListsToolbarActions
+ table={table}
+ onAddClick={() => setCreateDialogOpen(true)}
+ onCopyClick={() => setCopyDialogOpen(true)}
+ onToggleActive={(rows, newIsDeleted) =>
+ handleToggleActive(rows.map((r) => r.id), newIsDeleted)
+ }
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <AddPqDialog
+ open={createDialogOpen}
+ onOpenChange={setCreateDialogOpen}
+ onSubmit={handleCreate}
+ projects={projects}
+ isLoading={isPending}
+ />
+
+ <CopyPqDialog
+ open={copyDialogOpen}
+ onOpenChange={setCopyDialogOpen}
+ pqLists={activePqLists}
+ projects={projects}
+ onCopy={handleCopy}
+ isLoading={isPending}
+ />
+ </>
+ )
+}
diff --git a/lib/pq/table/pq-lists-toolbar.tsx b/lib/pq/table/pq-lists-toolbar.tsx
new file mode 100644
index 00000000..3a85327d
--- /dev/null
+++ b/lib/pq/table/pq-lists-toolbar.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Trash, CopyPlus, Plus } from "lucide-react"
+import { type Table } from "@tanstack/react-table"
+import type { PQList } from "./pq-lists-columns"
+// import { PqListForm } from "./add-pq-list-dialog"
+
+interface PQListsToolbarActionsProps {
+ table: Table<PQList>;
+ onAddClick: () => void;
+ onCopyClick: () => void;
+ onToggleActive: (rows: PQList[], newIsDeleted: boolean) => void;
+}
+
+export function PQListsToolbarActions({
+ table,
+ onAddClick,
+ onCopyClick,
+ onToggleActive,
+}: PQListsToolbarActionsProps) {
+ const selected = table.getFilteredSelectedRowModel().rows.map(r => r.original);
+ const allActive = selected.length > 0 && selected.every(item => !item.isDeleted);
+ const allDeleted = selected.length > 0 && selected.every(item => item.isDeleted);
+
+ let toggleLabel = "";
+ let newState: boolean | undefined;
+ if (selected.length > 0) {
+ if (allActive) {
+ toggleLabel = "비활성화";
+ newState = true;
+ } else if (allDeleted) {
+ toggleLabel = "활성화";
+ newState = false;
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {selected.length > 0 && (allActive || allDeleted) && newState !== undefined && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onToggleActive(selected, newState!)}
+ >
+ <Trash className="mr-2 h-4 w-4" />
+ {toggleLabel}
+ </Button>
+ )}
+ <Button size="sm" onClick={onAddClick}>
+ <Plus className="mr-2 h-4 w-4" />
+ 추가
+ </Button>
+ <Button size="sm" variant="outline" onClick={onCopyClick}>
+ <CopyPlus className="mr-2 h-4 w-4" />
+ 복사
+ </Button>
+ </div>
+ );
+}
diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts
index cf512d63..93daf460 100644
--- a/lib/pq/validations.ts
+++ b/lib/pq/validations.ts
@@ -1,74 +1,127 @@
-import {
- createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,
-} from "nuqs/server"
-import * as z from "zod"
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { PqCriterias, vendorPQSubmissions } from "@/db/schema/pq"
-
-export const searchParamsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<PqCriterias>().withDefault([
- { id: "createdAt", desc: true },
- ]),
- code: parseAsString.withDefault(""),
- checkPoint: parseAsString.withDefault(""),
- description: parseAsString.withDefault(""),
- remarks: parseAsString.withDefault(""),
- groupName: parseAsString.withDefault(""),
-
- // advanced filter
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- search: parseAsString.withDefault(""),
-
-})
-
-
-export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
-
-
-export const searchParamsPQReviewCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬
- sort: getSortingStateParser<typeof vendorPQSubmissions.$inferSelect>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (새로 추가)
- pqBasicFilters: getFiltersStateParser().withDefault([]),
- pqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // PQ 특화 필터 (기존 유지)
- vendorName: parseAsString.withDefault(""),
- vendorCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
- type: parseAsStringEnum(["GENERAL", "PROJECT"]),
- status: parseAsStringEnum(["REQUESTED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED"]),
- submittedDateFrom: parseAsString.withDefault(""),
- submittedDateTo: parseAsString.withDefault(""),
-});
-
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { PqCriterias, vendorPQSubmissions } from "@/db/schema/pq"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<PqCriterias>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ code: parseAsString.withDefault(""),
+ checkPoint: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+ remarks: parseAsString.withDefault(""),
+ groupName: parseAsString.withDefault(""),
+ subGroupName: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
+
+export const searchParamsPQReviewCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<typeof vendorPQSubmissions.$inferSelect>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (새로 추가)
+ pqBasicFilters: getFiltersStateParser().withDefault([]),
+ pqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // PQ 특화 필터 (기존 유지)
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ type: parseAsStringEnum(["GENERAL", "PROJECT", "NON_INSPECTION"]),
+ status: parseAsStringEnum(["REQUESTED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED"]),
+ submittedDateFrom: parseAsString.withDefault(""),
+ submittedDateTo: parseAsString.withDefault(""),
+});
+export const getPqListsSchema = z.object({
+ page: z.number().min(1).default(1),
+ perPage: z.number().min(1).max(100).default(20),
+ sort: z
+ .array(
+ z.object({
+ id: z.string(),
+ desc: z.boolean().optional(),
+ })
+ )
+ .default([]),
+ filters: z.any().optional(),
+ joinOperator: z.enum(["and", "or"]).default("and"),
+ search: z.string().optional(),
+});
+// CREATE PQ LIST
+export const createPqListSchema = z.object({
+ name: z.string().min(1, "PQ 목록 명을 입력해주세요"),
+ type: z.enum(["GENERAL", "PROJECT", "NON_INSPECTION"], {
+ required_error: "PQ 유형을 선택해주세요"
+ }),
+ projectId: z.number().optional().nullable(),
+ validTo: z.date().optional().nullable(),
+}).refine((data) => {
+ if (data.type === "PROJECT" && !data.projectId) {
+ return false;
+ }
+ return true;
+}, {
+ message: "프로젝트 PQ인 경우 프로젝트를 선택해야 합니다",
+ path: ["projectId"]
+});
+// PQ 목록 복사 (불러오기 기능)
+export const copyPqListSchema = z.object({
+ sourcePqListId: z.number({
+ required_error: "복사할 PQ 목록을 선택해주세요"
+ }),
+ targetProjectId: z.number({
+ required_error: "대상 프로젝트를 선택해주세요"
+ }),
+ newName: z.string().min(1, "새 PQ 목록 명을 입력해주세요").optional(),
+ validTo: z.date().optional().nullable(),
+});
+export type CreatePqListInput = z.infer<typeof createPqListSchema>;
+
+// // UPDATE PQ LIST
+// export const updatePqListSchema = createPqListSchema.extend({
+// id: z.number().min(1, "PQ 목록 ID가 필요합니다"),
+// });
+
+// export type UpdatePqListInput = z.infer<typeof updatePqListSchema>;
+
+export type CopyPqListInput = z.infer<typeof copyPqListSchema>;
+export type GetPqListsSchema = z.infer<typeof getPqListsSchema>;
export type GetPQSubmissionsSchema = Awaited<ReturnType<typeof searchParamsPQReviewCache.parse>> \ No newline at end of file
diff --git a/lib/site-visit/client-site-visit-wrapper.tsx b/lib/site-visit/client-site-visit-wrapper.tsx
new file mode 100644
index 00000000..4f056b3a
--- /dev/null
+++ b/lib/site-visit/client-site-visit-wrapper.tsx
@@ -0,0 +1,474 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { Building2, Calendar, Users, MessageSquare, Ellipsis, Eye, Edit, Download, Paperclip } from "lucide-react"
+import { toast } from "sonner"
+import { downloadFile } from "@/lib/file-download"
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { VendorInfoSheet } from "./vendor-info-sheet"
+import type { VendorInfoFormValues } from "./vendor-info-sheet"
+import { submitVendorInfoAction } from "./service"
+import { SiteVisitDetailDialog } from "./site-visit-detail-dialog"
+import { ShiAttendeesDialog } from "./shi-attendees-dialog"
+// SHI 참석자 총 인원수 계산 함수
+function getTotalShiAttendees(shiAttendees: Record<string, unknown> | null): number {
+ if (!shiAttendees) return 0
+
+ let total = 0
+ Object.entries(shiAttendees).forEach(([, value]) => {
+ if (value && typeof value === 'object' && 'checked' in value && 'count' in value) {
+ const attendee = value as { checked: boolean; count: number }
+ if (attendee.checked) {
+ total += attendee.count
+ }
+ }
+ })
+ return total
+}
+
+interface SiteVisitRequest {
+ id: number
+ investigationId: number
+ requesterId: number | null
+ inspectionDuration: string | null
+ requestedStartDate: Date | null
+ requestedEndDate: Date | null
+ shiAttendees: Record<string, unknown> | null
+ shiAttendeeDetails?: string | null
+ vendorRequests: Record<string, unknown> | null
+ additionalRequests: string | null
+ status: string
+ sentAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+
+ // 실사 정보
+ evaluationType: string | null //구매담당자가 작성한 실사방법
+ investigationMethod: string | null // QM담당자가 작성한 실사방법
+ investigationAddress: string | null
+ investigationNotes: string | null
+ forecastedAt: Date | null
+ actualAt: Date | null
+ result: string | null
+ resultNotes: string | null
+
+ // PQ 정보
+ pqItems: string | null
+
+ // 요청자 정보
+ requesterName: string | null
+ requesterEmail: string | null
+ requesterTitle: string | null
+
+ // QM 매니저 정보
+ qmManagerName: string | null
+ qmManagerEmail: string | null
+ qmManagerTitle: string | null
+
+ // 협력업체 정보
+ vendorInfo?: {
+ id: number
+ siteVisitRequestId: number
+ factoryName: string
+ factoryLocation: string
+ factoryAddress: string
+ factoryPicName: string
+ factoryPicPhone: string
+ factoryPicEmail: string
+ factoryDirections: string | null
+ accessProcedure: string | null
+ hasAttachments: boolean
+ otherInfo: string | null
+ submittedAt: Date
+ submittedBy: number
+ createdAt: Date
+ updatedAt: Date
+ } | null
+
+ // SHI 첨부파일
+ shiAttachments?: Array<{
+ id: number
+ siteVisitRequestId: number
+ vendorSiteVisitInfoId: number | null
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number
+ mimeType: string
+ createdAt: Date
+ updatedAt: Date
+ }> | null
+}
+
+interface ClientSiteVisitWrapperProps {
+ siteVisitRequests: SiteVisitRequest[]
+ vendorId: number
+}
+
+export function ClientSiteVisitWrapper({
+ siteVisitRequests,
+ vendorId,
+}: ClientSiteVisitWrapperProps) {
+ const [selectedRequest, setSelectedRequest] = React.useState<SiteVisitRequest | null>(null)
+ const [isDetailDialogOpen, setIsDetailDialogOpen] = React.useState(false)
+ const [isVendorInfoSheetOpen, setIsVendorInfoSheetOpen] = React.useState(false)
+ const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState<number | null>(null)
+ const [isShiAttendeesDialogOpen, setIsShiAttendeesDialogOpen] = React.useState(false)
+
+ const getInvestigationMethodLabel = (method: string | null) => {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method || "-"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "REQUESTED":
+ return "요청됨"
+ case "SENT":
+ return "발송됨"
+ case "COMPLETED":
+ return "완료"
+ case "VENDOR_SUBMITTED":
+ return "협력업체 제출"
+ default:
+ return status
+ }
+ }
+
+ const getStatusVariant = (status: string) => {
+ switch (status) {
+ case "REQUESTED":
+ return "secondary"
+ case "SENT":
+ return "default"
+ case "COMPLETED":
+ return "outline"
+ case "VENDOR_SUBMITTED":
+ return "default"
+ default:
+ return "secondary"
+ }
+ }
+
+ const formatDate = (date: Date | null) => {
+ if (!date) return "-"
+ return format(date, "yyyy.MM.dd", { locale: ko })
+ }
+
+ const formatDateRange = (startDate: Date | null, endDate: Date | null) => {
+ if (!startDate) return "-"
+ if (!endDate || startDate.getTime() === endDate.getTime()) {
+ return formatDate(startDate)
+ }
+ return `${formatDate(startDate)} ~ ${formatDate(endDate)}`
+ }
+
+ return (
+ <div className="container mx-auto py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-3xl font-bold">실사정보 관리</h1>
+ <p className="text-muted-foreground mt-2">
+ 방문실사 요청 정보를 조회하고 회신할 수 있습니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">Vendor ID: {vendorId}</Badge>
+ </div>
+ </div>
+
+ {/* 통계 카드 */}
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">전체 요청</CardTitle>
+ <Building2 className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{siteVisitRequests.length}</div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">발송됨</CardTitle>
+ <MessageSquare className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {siteVisitRequests.filter(r => r.status === "SENT").length}
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">완료</CardTitle>
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {siteVisitRequests.filter(r => r.status === "COMPLETED").length}
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">대기중</CardTitle>
+ <Users className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {siteVisitRequests.filter(r => r.status === "REQUESTED").length}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 테이블 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>방문실사 요청 목록</CardTitle>
+ <CardDescription>
+ SHI에서 요청한 방문실사 정보를 확인하고 회신할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-12">No.</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>실사품목</TableHead>
+ <TableHead>실사방법</TableHead>
+ <TableHead>실사기간</TableHead>
+ <TableHead>SHI 자료</TableHead>
+ <TableHead>실사요청일</TableHead>
+ <TableHead>실제 실사일</TableHead>
+ <TableHead>실사결과</TableHead>
+ <TableHead>SHI참석자</TableHead>
+
+ <TableHead className="w-20">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {siteVisitRequests.map((request, index) => (
+ <TableRow key={request.id}>
+ <TableCell className="font-medium">{index + 1}</TableCell>
+ <TableCell>
+ <Badge variant={getStatusVariant(request.status)}>
+ {getStatusLabel(request.status)}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ {/* 실사품목 - PQ에서 가져온 정보 표시 */}
+ {request.pqItems || "-"}
+ </TableCell>
+ <TableCell>
+ <Badge variant="outline">
+ {getInvestigationMethodLabel(request.investigationMethod)}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ {request.inspectionDuration ? `${request.inspectionDuration}일` : "-"}
+ </TableCell>
+ <TableCell>
+ {request.shiAttachments && request.shiAttachments.length > 0 ? (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 text-xs hover:bg-blue-50"
+ >
+ <Paperclip className="h-4 w-4 text-blue-600 mr-1" />
+ {request.shiAttachments.length}개
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-64">
+ {request.shiAttachments.map((attachment) => (
+ <DropdownMenuItem
+ key={attachment.id}
+ onSelect={() => {
+ downloadFile(
+ attachment.filePath,
+ attachment.originalFileName,
+ {
+ showToast: true,
+ onSuccess: (fileName) => {
+ toast.success(`파일 다운로드 완료: ${fileName}`);
+ },
+ onError: (error) => {
+ toast.error(`다운로드 실패: ${error}`);
+ }
+ }
+ );
+ }}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ {attachment.originalFileName}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ {formatDateRange(request.requestedStartDate, request.requestedEndDate)}
+ </TableCell>
+ <TableCell>
+ {formatDate(request.actualAt)}
+ </TableCell>
+ <TableCell>
+ {request.result ? (
+ <Badge variant={request.result === "APPROVED" ? "default" : "destructive"}>
+ {request.result === "APPROVED" ? "통과" : "불가"}
+ </Badge>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ {getTotalShiAttendees(request.shiAttendees) > 0 ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 text-xs"
+ onClick={() => {
+ setSelectedRequest(request)
+ setIsShiAttendeesDialogOpen(true)
+ }}
+ >
+ {getTotalShiAttendees(request.shiAttendees)}명
+ </Button>
+ ) : "-"}
+ </TableCell>
+
+ <TableCell>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => {
+ setSelectedRequest(request)
+ setIsDetailDialogOpen(true)
+ }}
+ >
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ setSelectedSiteVisitRequestId(request.id)
+ setIsVendorInfoSheetOpen(true)
+ }}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 정보입력
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+
+ {/* 상세 정보 다이얼로그 */}
+ <SiteVisitDetailDialog
+ isOpen={isDetailDialogOpen}
+ onOpenChange={setIsDetailDialogOpen}
+ selectedRequest={selectedRequest}
+ />
+
+ {/* 협력업체 정보 입력 Sheet */}
+ {selectedSiteVisitRequestId && (
+ <VendorInfoSheet
+ isOpen={isVendorInfoSheetOpen}
+ onClose={() => {
+ setIsVendorInfoSheetOpen(false)
+ setSelectedSiteVisitRequestId(null)
+ }}
+ onSubmit={async (data: VendorInfoFormValues & { attachments?: File[] }) => {
+ try {
+ const result = await submitVendorInfoAction({
+ siteVisitRequestId: selectedSiteVisitRequestId,
+ ...data
+ })
+ if (result.success) {
+ toast.success(result.message || "협력업체 정보가 성공적으로 제출되었습니다.")
+ // 페이지 새로고침으로 데이터 업데이트
+ window.location.reload()
+ } else {
+ toast.error(result.error || "협력업체 정보 제출 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("협력업체 정보 제출 오류:", error)
+ toast.error("협력업체 정보 제출 중 오류가 발생했습니다.")
+ }
+ }}
+ siteVisitRequestId={selectedSiteVisitRequestId}
+ initialData={siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo ? {
+ factoryName: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryName || "",
+ factoryLocation: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryLocation || "",
+ factoryAddress: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryAddress || "",
+ factoryPicName: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryPicName || "",
+ factoryPicPhone: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryPicPhone || "",
+ factoryPicEmail: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryPicEmail || "",
+ factoryDirections: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryDirections || "",
+ accessProcedure: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.accessProcedure || "",
+
+ hasAttachments: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.hasAttachments || false,
+ otherInfo: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.otherInfo || "",
+ } : null}
+ />
+ )}
+
+ {/* SHI 참석자 정보 다이얼로그 */}
+ <ShiAttendeesDialog
+ isOpen={isShiAttendeesDialogOpen}
+ onOpenChange={setIsShiAttendeesDialogOpen}
+ selectedRequest={selectedRequest}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/site-visit/service.ts b/lib/site-visit/service.ts
new file mode 100644
index 00000000..3b9bcb91
--- /dev/null
+++ b/lib/site-visit/service.ts
@@ -0,0 +1,668 @@
+"use server"
+
+import db from "@/db/db"
+import { and, eq, isNull, desc, sql} from "drizzle-orm";
+import { revalidatePath} from "next/cache";
+import { format } from "date-fns"
+import { vendorInvestigations, vendorPQSubmissions, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
+import { sendEmail } from "../mail/sendEmail";
+import { decryptWithServerAction } from '@/components/drm/drmUtils'
+
+import { vendors } from "@/db/schema/vendors";
+import { saveFile, saveDRMFile } from "@/lib/file-stroage";
+
+
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { users } from "@/db/schema"
+
+
+
+// 방문실사 요청 서버 액션
+export async function createSiteVisitRequestAction(input: {
+ investigationId: number;
+ inspectionDuration: number;
+ requestedStartDate: Date;
+ requestedEndDate: Date;
+ shiAttendees: Record<string, boolean>;
+ shiAttendeeDetails?: string;
+ vendorRequests: Record<string, boolean>;
+ otherVendorRequests?: string;
+ additionalRequests?: string;
+ attachments?: Array<File>;
+ }) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("Unauthorized");
+ }
+ const investigationId = Number(input.investigationId)
+
+ // 기존 방문실사 요청이 있는지 확인
+ const existingRequest = await db
+ .select()
+ .from(siteVisitRequests)
+ .where(eq(siteVisitRequests.investigationId, investigationId))
+ .limit(1);
+
+ if (existingRequest.length > 0) {
+ return {
+ success: false,
+ error: "이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다."
+ };
+ }
+
+ // 방문실사 요청 생성
+ const [siteVisitRequest] = await db
+ .insert(siteVisitRequests)
+ .values({
+ investigationId: investigationId,
+ requesterId: session.user.id,
+ inspectionDuration: input.inspectionDuration,
+ requestedStartDate: input.requestedStartDate,
+ requestedEndDate: input.requestedEndDate,
+ shiAttendees: input.shiAttendees,
+ vendorRequests: input.vendorRequests,
+ additionalRequests: input.additionalRequests,
+ status: "REQUESTED",
+ })
+ .returning();
+
+ // SHI 첨부파일 처리
+ if (input.attachments && input.attachments.length > 0) {
+ console.log(`📎 첨부파일 처리 시작: ${input.attachments.length}개 파일`);
+
+ const attachmentValues = [];
+
+ for (const file of input.attachments) {
+ try {
+ console.log(`📁 파일 처리 중: ${file.name} (${file.size} bytes)`);
+
+ // saveDRMFile을 사용하여 파일 저장
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `site-visit-requests/${siteVisitRequest.id}`,
+ session.user.id.toString()
+ );
+
+ if (!saveResult.success) {
+ console.error(`❌ 파일 저장 실패: ${file.name}`, saveResult.error);
+ throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`);
+ }
+
+ console.log(`✅ 파일 저장 완료: ${file.name} -> ${saveResult.fileName}`);
+
+ // DB에 첨부파일 레코드 생성
+ const attachmentValue = {
+ siteVisitRequestId: siteVisitRequest.id,
+ vendorSiteVisitInfoId: null, // SHI 첨부파일은 vendorSiteVisitInfoId가 null
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ attachmentValues.push(attachmentValue);
+
+ } catch (error) {
+ console.error(`❌ 첨부파일 처리 오류: ${file.name}`, error);
+ throw new Error(`첨부파일 처리 중 오류가 발생했습니다: ${file.name}`);
+ }
+ }
+
+ if (attachmentValues.length > 0) {
+ await db.insert(siteVisitRequestAttachments).values(attachmentValues);
+ console.log(`✅ 첨부파일 DB 저장 완료: ${attachmentValues.length}개`);
+ }
+ }
+
+ // 이메일 발송
+ try {
+ // 실사, 협력업체, 발송자 정보 조회
+ const investigationResult = await db
+ .select()
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, siteVisitRequest.investigationId))
+ .limit(1);
+
+ const investigation = investigationResult[0];
+ if (!investigation) {
+ throw new Error('실사 정보를 찾을 수 없습니다.');
+ }
+
+ const vendorResult = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, investigation.vendorId))
+ .limit(1);
+
+ const vendor = vendorResult[0];
+ if (!vendor) {
+ throw new Error('협력업체 정보를 찾을 수 없습니다.');
+ }
+
+ const senderResult = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, siteVisitRequest.requesterId))
+ .limit(1);
+
+ const sender = senderResult[0];
+ if (!sender) {
+ throw new Error('발송자 정보를 찾을 수 없습니다.');
+ }
+
+ // 평가 유형 라벨 및 설명
+ const getEvaluationTypeInfo = (type: string) => {
+ switch (type) {
+ case 'PRODUCT_INSPECTION':
+ return {
+ label: '제품검사평가',
+ description: '제품의 품질, 성능, 안전성 등을 직접 검사하는 평가'
+ };
+ case 'SITE_VISIT_EVAL':
+ return {
+ label: '방문실사평가',
+ description: '공장 시설, 생산능력, 품질관리체계 등을 현장에서 점검하는 평가'
+ };
+ default:
+ return {
+ label: type,
+ description: ''
+ };
+ }
+ };
+
+ const evaluationTypeInfo = getEvaluationTypeInfo(investigation.evaluationType || '');
+
+ // 마감일 계산 (발송일 + 7일)
+ const deadlineDate = format(new Date(), 'yyyy.MM.dd');
+
+ // SHI 참석자 정보 파싱 (새로운 구조에 맞게)
+ const shiAttendees = input.shiAttendees as Record<string, { checked: boolean; count: number; details?: string }>;
+
+ // 메일 제목
+ const subject = `[SHI Audit] 방문실사 시행 안내 및 실사 관련 추가정보 요청 _ ${vendor.vendorName} (${vendor.vendorCode}, 사업자번호: ${vendor.taxId})`;
+
+ // 메일 컨텍스트
+ const context = {
+ // 기본 정보
+ vendorName: vendor.vendorName,
+ vendorContactName: vendor.vendorName || '',
+ requesterName: sender.name,
+ requesterTitle: 'Procurement Manager',
+ requesterEmail: sender.email,
+
+ // 실사 정보
+ evaluationType: evaluationTypeInfo.label,
+ evaluationTypeDescription: evaluationTypeInfo.description,
+ requestedStartDate: format(siteVisitRequest.requestedStartDate!, 'yyyy.MM.dd'),
+ requestedEndDate: format(siteVisitRequest.requestedEndDate!, 'yyyy.MM.dd'),
+ inspectionDuration: siteVisitRequest.inspectionDuration,
+
+ // 마감일
+ deadlineDate,
+
+ // SHI 참석자 정보 (새로운 구조)
+ shiAttendees: Object.entries(shiAttendees)
+ .filter(([, value]) => value.checked)
+ .map(([key, value]) => {
+ const departmentLabels: Record<string, string> = {
+ technicalSales: "기술영업",
+ design: "설계",
+ procurement: "구매",
+ quality: "품질",
+ production: "생산",
+ commissioning: "시운전",
+ other: "기타"
+ };
+ const departmentName = departmentLabels[key] || key;
+ const details = value.details ? ` (${value.details})` : '';
+ return `${departmentName} ${value.count}명${details}`;
+ }),
+ shiAttendeeDetails: input.shiAttendeeDetails || null,
+
+ // 협력업체 요청 정보
+ vendorRequests: Object.keys(siteVisitRequest.vendorRequests as Record<string, boolean>)
+ .filter(key => (siteVisitRequest.vendorRequests as Record<string, boolean>)[key])
+ .map(key => {
+ const requestLabels = {
+ 'factoryName': '○ 실사공장명',
+ 'factoryLocation': '○ 실사공장 주소',
+ 'factoryDirections': '○ 실사공장 가는 방법',
+ 'factoryPicName': '○ 실사공장 Contact Point',
+ 'factoryPicPhone': '○ 실사공장 연락처',
+ 'factoryPicEmail': '○ 실사공장 이메일',
+ 'attendees': '○ 실사 참석 예정인력',
+ 'accessProcedure': '○ 공장 출입절차 및 준비물'
+ };
+ return requestLabels[key as keyof typeof requestLabels] || key;
+ }),
+ otherVendorRequests: input.otherVendorRequests,
+
+ // 추가 요청사항
+ additionalRequests: siteVisitRequest.additionalRequests,
+
+ // 포털 URL
+ portalUrl: `${process.env.NEXTAUTH_URL}/ko/partners/site-visit`,
+
+ // 현재 연도
+ currentYear: new Date().getFullYear()
+ };
+
+ // 메일 발송 (벤더 이메일로 직접 발송)
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: 'site-visit-request' as string,
+ context,
+ cc: vendor.email !== sender.email ? sender.email : undefined
+ });
+
+ console.log('방문실사 요청 메일 발송 완료:', {
+ to: vendor.email,
+ subject,
+ vendorName: vendor.vendorName
+ });
+
+ // 메일 발송 성공 시 상태 업데이트
+ await db
+ .update(siteVisitRequests)
+ .set({
+ status: "SENT",
+ sentAt: new Date()
+ })
+ .where(eq(siteVisitRequests.id, siteVisitRequest.id));
+
+ } catch (emailError) {
+ console.error('방문실사 요청 메일 발송 실패:', emailError);
+ }
+
+ revalidatePath("/evcp/pq_new");
+
+ return {
+ success: true,
+ data: siteVisitRequest,
+ message: "방문실사 요청이 성공적으로 생성되었습니다."
+ };
+
+ } catch (error) {
+ console.error("방문실사 요청 생성 오류:", error);
+ return {
+ success: false,
+ error: "방문실사 요청 생성 중 오류가 발생했습니다."
+ };
+ }
+ }
+
+ // 방문실사 요청 조회 서버 액션
+export async function getSiteVisitRequestAction(investigationId: number) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("Unauthorized");
+ }
+
+ const siteVisitRequest = await db
+ .select()
+ .from(siteVisitRequests)
+ .where(eq(siteVisitRequests.investigationId, investigationId))
+ .limit(1);
+
+ if (!siteVisitRequest[0]) {
+ return {
+ success: true,
+ data: null
+ };
+ }
+
+ // SHI 첨부파일 조회 (vendorSiteVisitInfoId가 null인 것들)
+ const shiAttachments = await db
+ .select()
+ .from(siteVisitRequestAttachments)
+ .where(
+ and(
+ eq(siteVisitRequestAttachments.siteVisitRequestId, siteVisitRequest[0].id),
+ isNull(siteVisitRequestAttachments.vendorSiteVisitInfoId)
+ )
+ );
+
+ return {
+ success: true,
+ data: {
+ ...siteVisitRequest[0],
+ shiAttachments
+ }
+ };
+
+ } catch (error) {
+ console.error("방문실사 요청 조회 오류:", error);
+ return {
+ success: false,
+ error: "방문실사 요청 조회 중 오류가 발생했습니다."
+ };
+ }
+ }
+
+ // 협력업체용 방문실사 요청 조회
+ export async function getSiteVisitRequestsByVendorId(vendorId: number) {
+ try {
+ const result = await db
+ .select({
+ id: siteVisitRequests.id,
+ investigationId: siteVisitRequests.investigationId,
+ requesterId: siteVisitRequests.requesterId,
+ inspectionDuration: siteVisitRequests.inspectionDuration,
+ requestedStartDate: siteVisitRequests.requestedStartDate,
+ requestedEndDate: siteVisitRequests.requestedEndDate,
+ shiAttendees: siteVisitRequests.shiAttendees,
+ vendorRequests: siteVisitRequests.vendorRequests,
+ additionalRequests: siteVisitRequests.additionalRequests,
+ status: siteVisitRequests.status,
+ sentAt: siteVisitRequests.sentAt,
+ createdAt: siteVisitRequests.createdAt,
+ updatedAt: siteVisitRequests.updatedAt,
+
+ // 실사 정보
+ evaluationType: vendorInvestigations.evaluationType,
+ investigationMethod: vendorInvestigations.investigationMethod,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ investigationNotes: vendorInvestigations.investigationNotes,
+ forecastedAt: vendorInvestigations.forecastedAt,
+ actualAt: vendorInvestigations.completedAt,
+ result: vendorInvestigations.evaluationResult,
+ resultNotes: vendorInvestigations.purchaseComment,
+
+ // PQ 정보
+ pqItems: vendorPQSubmissions.pqItems,
+
+
+ // 협력업체 정보
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email,
+ })
+ .from(siteVisitRequests)
+ .leftJoin(
+ vendorInvestigations,
+ eq(siteVisitRequests.investigationId, vendorInvestigations.id)
+ )
+ .leftJoin(
+ sql`users AS requester`,
+ eq(siteVisitRequests.requesterId, sql`requester.id`)
+ )
+ .leftJoin(
+ vendors,
+ eq(vendorInvestigations.vendorId, vendors.id)
+ )
+ .leftJoin(
+ vendorPQSubmissions,
+ eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id)
+ )
+ .where(eq(vendorInvestigations.vendorId, vendorId))
+ .orderBy(desc(siteVisitRequests.createdAt));
+
+ // 각 방문실사 요청에 대해 협력업체 정보 조회
+ const resultWithVendorInfo = await Promise.all(
+ result.map(async (item) => {
+ const vendorInfoResult = await db
+ .select({
+ id: vendorSiteVisitInfo.id,
+ siteVisitRequestId: vendorSiteVisitInfo.siteVisitRequestId,
+ factoryName: vendorSiteVisitInfo.factoryName,
+ factoryLocation: vendorSiteVisitInfo.factoryLocation,
+ factoryAddress: vendorSiteVisitInfo.factoryAddress,
+ factoryPicName: vendorSiteVisitInfo.factoryPicName,
+ factoryPicPhone: vendorSiteVisitInfo.factoryPicPhone,
+ factoryPicEmail: vendorSiteVisitInfo.factoryPicEmail,
+ factoryDirections: vendorSiteVisitInfo.factoryDirections,
+ accessProcedure: vendorSiteVisitInfo.accessProcedure,
+
+ hasAttachments: vendorSiteVisitInfo.hasAttachments,
+ otherInfo: vendorSiteVisitInfo.otherInfo,
+ submittedAt: vendorSiteVisitInfo.submittedAt,
+ submittedBy: vendorSiteVisitInfo.submittedBy,
+ createdAt: vendorSiteVisitInfo.createdAt,
+ updatedAt: vendorSiteVisitInfo.updatedAt,
+ })
+ .from(vendorSiteVisitInfo)
+ .where(eq(vendorSiteVisitInfo.siteVisitRequestId, item.id))
+ .limit(1);
+
+ const vendorInfo = vendorInfoResult.length > 0 ? vendorInfoResult[0] : null;
+
+ // SHI 첨부파일 조회 (vendorSiteVisitInfoId가 null인 것들)
+ const shiAttachments = await db
+ .select()
+ .from(siteVisitRequestAttachments)
+ .where(
+ and(
+ eq(siteVisitRequestAttachments.siteVisitRequestId, item.id),
+ isNull(siteVisitRequestAttachments.vendorSiteVisitInfoId)
+ )
+ );
+
+ return {
+ ...item,
+ shiAttendees: item.shiAttendees as Record<string, unknown> | null,
+ vendorRequests: item.vendorRequests as Record<string, unknown> | null,
+ vendorInfo,
+ shiAttachments,
+ };
+ })
+ );
+
+ console.log(`📊 방문실사 요청 조회 완료 - 총 ${resultWithVendorInfo.length}개 요청`)
+ console.log(`🔍 실제실사일/실사결과 데이터 확인:`, resultWithVendorInfo.map(item => ({
+ id: item.id,
+ actualAt: item.actualAt,
+ result: item.result,
+ investigationId: item.investigationId
+ })))
+
+ return resultWithVendorInfo;
+ } catch (error) {
+ console.error("방문실사 요청 조회 오류:", error);
+ return [];
+ }
+ }
+
+ // 협력업체 정보 제출 서버 액션
+ export async function submitVendorInfoAction(input: {
+ siteVisitRequestId: number;
+ factoryName: string;
+ factoryLocation: string;
+ factoryAddress: string;
+ factoryPicName: string;
+ factoryPicPhone: string;
+ factoryPicEmail: string;
+ factoryDirections: string;
+ accessProcedure: string;
+
+ hasAttachments: boolean;
+ otherInfo?: string;
+ attachments?: Array<File>;
+ }) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("Unauthorized");
+ }
+
+ // 기존 협력업체 정보가 있는지 확인
+ const existingInfo = await db
+ .select()
+ .from(vendorSiteVisitInfo)
+ .where(eq(vendorSiteVisitInfo.siteVisitRequestId, input.siteVisitRequestId))
+ .limit(1);
+
+ if (existingInfo.length > 0) {
+ // 기존 정보 업데이트
+ await db
+ .update(vendorSiteVisitInfo)
+ .set({
+ factoryName: input.factoryName,
+ factoryLocation: input.factoryLocation,
+ factoryAddress: input.factoryAddress,
+ factoryPicName: input.factoryPicName,
+ factoryPicPhone: input.factoryPicPhone,
+ factoryPicEmail: input.factoryPicEmail,
+ factoryDirections: input.factoryDirections,
+ accessProcedure: input.accessProcedure,
+
+ hasAttachments: input.hasAttachments,
+ otherInfo: input.otherInfo,
+ // submittedBy: session.user.id,
+ submittedAt: new Date(),
+ })
+ .where(eq(vendorSiteVisitInfo.siteVisitRequestId, input.siteVisitRequestId));
+ } else {
+ // 새로운 정보 삽입
+ await db
+ .insert(vendorSiteVisitInfo)
+ .values({
+ siteVisitRequestId: input.siteVisitRequestId,
+ factoryName: input.factoryName,
+ factoryLocation: input.factoryLocation,
+ factoryAddress: input.factoryAddress,
+ factoryPicName: input.factoryPicName,
+ factoryPicPhone: input.factoryPicPhone,
+ factoryPicEmail: input.factoryPicEmail,
+ factoryDirections: input.factoryDirections,
+ accessProcedure: input.accessProcedure,
+
+ hasAttachments: input.hasAttachments,
+ otherInfo: input.otherInfo,
+ submittedBy: session.user.id,
+ });
+ }
+
+ // 첨부파일 처리
+ if (input.attachments && input.attachments.length > 0) {
+ console.log(`📎 협력업체 첨부파일 처리 시작: ${input.attachments.length}개 파일`);
+
+ // 기존 첨부파일 삭제 (업데이트 시)
+ if (existingInfo.length > 0) {
+ console.log(`🗑️ 기존 첨부파일 삭제: vendorSiteVisitInfoId ${existingInfo[0].id}`);
+ await db
+ .delete(siteVisitRequestAttachments)
+ .where(eq(siteVisitRequestAttachments.vendorSiteVisitInfoId, existingInfo[0].id));
+ }
+
+ const attachmentValues = [];
+
+ for (const file of input.attachments) {
+ try {
+ console.log(`📁 협력업체 파일 처리 중: ${file.name} (${file.size} bytes)`);
+
+ // saveFile을 사용하여 파일 저장 (협력업체 첨부파일은 일반 파일로 처리)
+ const saveResult = await saveFile({
+ file,
+ directory: `site-visit-vendor-info/${input.siteVisitRequestId}`,
+ originalName: file.name,
+ userId: session.user.id.toString()
+ });
+
+ if (!saveResult.success) {
+ console.error(`❌ 협력업체 파일 저장 실패: ${file.name}`, saveResult.error);
+ throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`);
+ }
+
+ console.log(`✅ 협력업체 파일 저장 완료: ${file.name} -> ${saveResult.fileName}`);
+
+ // DB에 첨부파일 레코드 생성
+ const attachmentValue = {
+ siteVisitRequestId: input.siteVisitRequestId,
+ vendorSiteVisitInfoId: existingInfo.length > 0 ? existingInfo[0].id : undefined,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ attachmentValues.push(attachmentValue);
+
+ } catch (error) {
+ console.error(`❌ 협력업체 첨부파일 처리 오류: ${file.name}`, error);
+ throw new Error(`첨부파일 처리 중 오류가 발생했습니다: ${file.name}`);
+ }
+ }
+
+ if (attachmentValues.length > 0) {
+ await db.insert(siteVisitRequestAttachments).values(attachmentValues);
+ console.log(`✅ 협력업체 첨부파일 DB 저장 완료: ${attachmentValues.length}개`);
+ }
+ }
+
+ // 방문실사 요청 상태 업데이트
+ await db
+ .update(siteVisitRequests)
+ .set({
+ status: "VENDOR_SUBMITTED"
+ })
+ .where(eq(siteVisitRequests.id, input.siteVisitRequestId));
+
+ revalidatePath("/partners/site-visit");
+
+ return {
+ success: true,
+ message: "협력업체 정보가 성공적으로 제출되었습니다."
+ };
+
+ } catch (error) {
+ console.error("협력업체 정보 제출 오류:", error);
+ return {
+ success: false,
+ error: "협력업체 정보 제출 중 오류가 발생했습니다."
+ };
+ }
+ }
+
+ // SHI eVCP에서 협력업체 방문실사 정보 조회
+ export async function getVendorSiteVisitInfoAction(siteVisitRequestId: number) {
+ try {
+ // 새로운 테이블에서 협력업체 정보 조회
+ const vendorInfoResult = await db
+ .select()
+ .from(vendorSiteVisitInfo)
+ .where(eq(vendorSiteVisitInfo.siteVisitRequestId, siteVisitRequestId))
+ .limit(1);
+
+ const vendorInfo = vendorInfoResult.length > 0 ? vendorInfoResult[0] : null;
+
+ if (!vendorInfo) {
+ return {
+ success: false,
+ error: "해당 방문실사 요청에 대한 협력업체 정보가 없습니다."
+ };
+ }
+
+ // 첨부파일 조회
+ const attachments = await db
+ .select()
+ .from(siteVisitRequestAttachments)
+ .where(eq(siteVisitRequestAttachments.vendorSiteVisitInfoId, vendorInfo.id));
+
+ return {
+ success: true,
+ data: {
+ vendorInfo,
+ attachments
+ }
+ };
+
+ } catch (error) {
+ console.error("협력업체 방문실사 정보 조회 오류:", error);
+ return {
+ success: false,
+ error: "협력업체 방문실사 정보 조회 중 오류가 발생했습니다."
+ };
+ }
+ } \ No newline at end of file
diff --git a/lib/site-visit/shi-attendees-dialog.tsx b/lib/site-visit/shi-attendees-dialog.tsx
new file mode 100644
index 00000000..b90689f4
--- /dev/null
+++ b/lib/site-visit/shi-attendees-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { Badge } from "@/components/ui/badge"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+interface SiteVisitRequest {
+ id: number
+ investigationId: number
+ requesterId: number | null
+ inspectionDuration: string | null
+ requestedStartDate: Date | null
+ requestedEndDate: Date | null
+ shiAttendees: Record<string, unknown> | null
+ shiAttendeeDetails?: string | null
+ vendorRequests: Record<string, unknown> | null
+ additionalRequests: string | null
+ status: string
+ sentAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+
+ // 실사 정보
+ evaluationType: string | null //구매담당자가 작성한 실사방법
+ investigationMethod: string | null // QM담당자가 작성한 실사방법
+ investigationAddress: string | null
+ investigationNotes: string | null
+ forecastedAt: Date | null
+ actualAt: Date | null
+ result: string | null
+ resultNotes: string | null
+
+ // PQ 정보
+ pqItems: string | null
+
+ // 요청자 정보
+ requesterName: string | null
+ requesterEmail: string | null
+ requesterTitle: string | null
+
+ // QM 매니저 정보
+ qmManagerName: string | null
+ qmManagerEmail: string | null
+ qmManagerTitle: string | null
+
+ // 협력업체 정보
+ vendorInfo?: {
+ id: number
+ siteVisitRequestId: number
+ factoryName: string
+ factoryLocation: string
+ factoryAddress: string
+ factoryPicName: string
+ factoryPicPhone: string
+ factoryPicEmail: string
+ factoryDirections: string | null
+ accessProcedure: string | null
+ hasAttachments: boolean
+ otherInfo: string | null
+ submittedAt: Date
+ submittedBy: number
+ createdAt: Date
+ updatedAt: Date
+ } | null
+
+ // SHI 첨부파일
+ shiAttachments?: Array<{
+ id: number
+ siteVisitRequestId: number
+ vendorSiteVisitInfoId: number | null
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number
+ mimeType: string
+ createdAt: Date
+ updatedAt: Date
+ }> | null
+}
+
+interface ShiAttendeesDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRequest: SiteVisitRequest | null
+}
+
+export function ShiAttendeesDialog({
+ isOpen,
+ onOpenChange,
+ selectedRequest,
+}: ShiAttendeesDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>SHI 참석자 정보</DialogTitle>
+ <DialogDescription>
+ 방문실사에 참석 예정인 SHI 인력 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {selectedRequest && selectedRequest.shiAttendees && (
+ <div className="space-y-4">
+ {Object.entries(selectedRequest.shiAttendees as Record<string, unknown>).map(([key, value]) => {
+ if (value && typeof value === 'object' && 'checked' in value && value.checked) {
+ const attendee = value as { checked: boolean; count: number; details?: string }
+ const departmentLabels: Record<string, string> = {
+ technicalSales: "기술영업",
+ design: "설계",
+ procurement: "구매",
+ quality: "품질",
+ production: "생산",
+ commissioning: "시운전",
+ other: "기타"
+ }
+
+ return (
+ <div key={key} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-semibold">{departmentLabels[key] || key}</h4>
+ <Badge variant="outline">{attendee.count}명</Badge>
+ </div>
+ {attendee.details && (
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">참석자 정보:</span> {attendee.details}
+ </div>
+ )}
+ </div>
+ )
+ }
+ return null
+ })}
+
+ {/* 전체 참석자 상세정보 */}
+ {selectedRequest.shiAttendeeDetails && (
+ <div className="border rounded-lg p-4">
+ <h4 className="font-semibold mb-2">전체 참석자 상세정보</h4>
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.shiAttendeeDetails}</p>
+ </div>
+ )}
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/site-visit/site-visit-detail-dialog.tsx b/lib/site-visit/site-visit-detail-dialog.tsx
new file mode 100644
index 00000000..714ca3e3
--- /dev/null
+++ b/lib/site-visit/site-visit-detail-dialog.tsx
@@ -0,0 +1,266 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { FileText, Download } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Separator } from "@/components/ui/separator"
+
+interface SiteVisitRequest {
+ id: number
+ investigationId: number
+ requesterId: number | null
+ inspectionDuration: string | null
+ requestedStartDate: Date | null
+ requestedEndDate: Date | null
+ shiAttendees: Record<string, unknown> | null
+ shiAttendeeDetails?: string | null
+ vendorRequests: Record<string, unknown> | null
+ additionalRequests: string | null
+ status: string
+ sentAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+
+ // 실사 정보
+ evaluationType: string | null //구매담당자가 작성한 실사방법
+ investigationMethod: string | null // QM담당자가 작성한 실사방법
+ investigationAddress: string | null
+ investigationNotes: string | null
+ forecastedAt: Date | null
+ actualAt: Date | null
+ result: string | null
+ resultNotes: string | null
+
+ // PQ 정보
+ pqItems: string | null
+
+ // 요청자 정보
+ requesterName: string | null
+ requesterEmail: string | null
+ requesterTitle: string | null
+
+ // QM 매니저 정보
+ qmManagerName: string | null
+ qmManagerEmail: string | null
+ qmManagerTitle: string | null
+
+ // 협력업체 정보
+ vendorInfo?: {
+ id: number
+ siteVisitRequestId: number
+ factoryName: string
+ factoryLocation: string
+ factoryAddress: string
+ factoryPicName: string
+ factoryPicPhone: string
+ factoryPicEmail: string
+ factoryDirections: string | null
+ accessProcedure: string | null
+ hasAttachments: boolean
+ otherInfo: string | null
+ submittedAt: Date
+ submittedBy: number
+ createdAt: Date
+ updatedAt: Date
+ } | null
+
+ // SHI 첨부파일
+ shiAttachments?: Array<{
+ id: number
+ siteVisitRequestId: number
+ vendorSiteVisitInfoId: number | null
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number
+ mimeType: string
+ createdAt: Date
+ updatedAt: Date
+ }> | null
+}
+
+interface SiteVisitDetailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRequest: SiteVisitRequest | null
+}
+
+export function SiteVisitDetailDialog({
+ isOpen,
+ onOpenChange,
+ selectedRequest,
+}: SiteVisitDetailDialogProps) {
+
+ const formatDate = (date: Date | null) => {
+ if (!date) return "-"
+ return format(date, "yyyy.MM.dd", { locale: ko })
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>방문실사 상세 정보</DialogTitle>
+ <DialogDescription>
+ 작성한 방문실사 정보의 상세 내용입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {selectedRequest && (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+
+ {/* 협력업체 정보 */}
+ {selectedRequest.vendorInfo && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="font-semibold mb-2">작성한 협력업체 정보</h3>
+ <div className="bg-muted p-4 rounded-md">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-semibold mb-2">공장 기본 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">공장명:</span> {selectedRequest.vendorInfo.factoryName}</div>
+ <div><span className="font-medium">공장위치:</span> {selectedRequest.vendorInfo.factoryLocation}</div>
+ <div><span className="font-medium">공장주소:</span> {selectedRequest.vendorInfo.factoryAddress}</div>
+ </div>
+ </div>
+
+ <div>
+ <h4 className="font-semibold mb-2">공장 PIC 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">이름:</span> {selectedRequest.vendorInfo.factoryPicName}</div>
+ <div><span className="font-medium">전화번호:</span> {selectedRequest.vendorInfo.factoryPicPhone}</div>
+ <div><span className="font-medium">이메일:</span> {selectedRequest.vendorInfo.factoryPicEmail}</div>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ {selectedRequest.vendorInfo.factoryDirections && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 가는 법</h4>
+ <div className="bg-background p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.vendorInfo.factoryDirections}</p>
+ </div>
+ </div>
+ )}
+
+ {selectedRequest.vendorInfo.accessProcedure && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 출입절차</h4>
+ <div className="bg-background p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.vendorInfo.accessProcedure}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 기타 정보 */}
+ {selectedRequest.vendorInfo.otherInfo && (
+ <div className="mt-6">
+ <h4 className="font-semibold mb-2">기타 정보</h4>
+ <div className="bg-background p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.vendorInfo.otherInfo}</p>
+ </div>
+ </div>
+ )}
+
+ {/* 제출 정보 */}
+ <div className="mt-6">
+ <h4 className="font-semibold mb-2">제출 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">제출일:</span> {formatDate(selectedRequest.vendorInfo.submittedAt)}</div>
+ <div><span className="font-medium">첨부파일:</span> {selectedRequest.vendorInfo.hasAttachments ? "있음" : "없음"}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* SHI 첨부파일 */}
+ {selectedRequest.shiAttachments && selectedRequest.shiAttachments.length > 0 && (
+ <>
+ <div>
+ <h3 className="font-semibold mb-2">SHI 첨부파일 ({selectedRequest.shiAttachments.length}개)</h3>
+ <div className="bg-muted p-4 rounded-md">
+ <div className="space-y-2">
+ {selectedRequest.shiAttachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{attachment.originalFileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round((attachment.fileSize || 0) / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="p-2"
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(attachment.filePath, attachment.originalFileName || '', {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error("다운로드 실패: " + error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error("파일 다운로드 중 오류가 발생했습니다.")
+ }
+ }}
+ aria-label="파일 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+
+ {/* 추가 요청사항 */}
+ {selectedRequest.additionalRequests && (
+ <>
+ <div>
+ <h3 className="font-semibold mb-2">SHI 추가 요청사항</h3>
+ <div className="bg-muted p-4 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.additionalRequests}</p>
+ </div>
+ </div>
+ <Separator />
+ </>
+ )}
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/site-visit/vendor-info-sheet.tsx b/lib/site-visit/vendor-info-sheet.tsx
new file mode 100644
index 00000000..c0b1ab7e
--- /dev/null
+++ b/lib/site-visit/vendor-info-sheet.tsx
@@ -0,0 +1,442 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import { toast } from "sonner"
+import { Upload, X, FileText } from "lucide-react"
+
+// 협력업체 정보 입력 스키마
+const vendorInfoSchema = z.object({
+ // 공장 정보
+ factoryName: z.string().min(1, "공장명을 입력해주세요."),
+ factoryLocation: z.string().min(1, "공장위치를 입력해주세요."),
+ factoryAddress: z.string().min(1, "공장주소를 입력해주세요."),
+
+ // 공장 PIC 정보
+ factoryPicName: z.string().min(1, "공장 PIC 이름을 입력해주세요."),
+ factoryPicPhone: z.string().min(1, "공장 PIC 전화번호를 입력해주세요."),
+ factoryPicEmail: z.string().email("올바른 이메일 주소를 입력해주세요."),
+
+ // 공장 가는 법
+ factoryDirections: z.string().min(1, "공장 가는 법을 입력해주세요."),
+
+ // 공장 출입절차
+ accessProcedure: z.string().min(1, "공장 출입절차를 입력해주세요."),
+
+ // 첨부파일
+ hasAttachments: z.boolean().default(false),
+
+ // 기타 정보
+ otherInfo: z.string().optional(),
+})
+
+export type VendorInfoFormValues = z.infer<typeof vendorInfoSchema>
+
+interface VendorInfoSheetProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: VendorInfoFormValues & { attachments?: File[] }) => Promise<void>
+ siteVisitRequestId: number
+ initialData?: VendorInfoFormValues | null
+}
+
+export function VendorInfoSheet({
+ isOpen,
+ onClose,
+ onSubmit,
+ siteVisitRequestId,
+ initialData,
+}: VendorInfoSheetProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ const form = useForm<VendorInfoFormValues>({
+ resolver: zodResolver(vendorInfoSchema),
+ defaultValues: {
+ factoryName: "",
+ factoryLocation: "",
+ factoryAddress: "",
+ factoryPicName: "",
+ factoryPicPhone: "",
+ factoryPicEmail: "",
+ factoryDirections: "",
+ accessProcedure: "",
+
+ hasAttachments: false,
+ otherInfo: "",
+ },
+ })
+
+ // Sheet가 열릴 때마다 폼 재설정
+ React.useEffect(() => {
+ if (isOpen) {
+ if (initialData) {
+ form.reset(initialData)
+ } else {
+ form.reset({
+ factoryName: "",
+ factoryLocation: "",
+ factoryAddress: "",
+ factoryPicName: "",
+ factoryPicPhone: "",
+ factoryPicEmail: "",
+ factoryDirections: "",
+ accessProcedure: "",
+
+ hasAttachments: false,
+ otherInfo: "",
+ })
+ }
+ }
+ }, [isOpen, form, initialData])
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files || files.length === 0) return
+
+ const newFiles = Array.from(files)
+
+ // 파일 크기 체크 (10MB)
+ const validFiles = newFiles.filter(file => {
+ if (file.size > 10 * 1024 * 1024) {
+ toast.error(`${file.name}: 파일 크기가 10MB를 초과합니다.`)
+ return false
+ }
+ return true
+ })
+
+ if (validFiles.length > 0) {
+ setSelectedFiles(prev => [...prev, ...validFiles])
+ form.setValue("hasAttachments", true)
+ toast.success(`${validFiles.length}개 파일이 추가되었습니다.`)
+ }
+ }
+
+ // 파일 삭제 핸들러
+ const handleRemoveFile = (index: number) => {
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index))
+ const newFileCount = selectedFiles.length - 1
+ form.setValue("hasAttachments", newFileCount > 0)
+ }
+
+ async function handleSubmit(data: VendorInfoFormValues) {
+ setIsPending(true)
+ try {
+ // 첨부파일 정보를 포함하여 제출
+ const submitData = {
+ ...data,
+ siteVisitRequestId,
+ attachments: selectedFiles
+ }
+ await onSubmit(submitData)
+ toast.success("협력업체 정보가 성공적으로 제출되었습니다.")
+ onClose()
+ } catch (error) {
+ toast.error("협력업체 정보 제출 중 오류가 발생했습니다.")
+ console.error("협력업체 정보 제출 오류:", error)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <SheetContent className="w-[600px] sm:w-[700px] overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>협력업체 정보 입력</SheetTitle>
+ <SheetDescription>
+ 방문실사 관련 협력업체 정보를 입력해주세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 공장 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 정보</h3>
+
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="factoryName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="공장명을 입력하세요" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryLocation"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장위치 *</FormLabel>
+ <FormControl>
+ <Input placeholder="국가 또는 지역 (예: Finland, 부산)" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장주소 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 주소를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 공장 PIC 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 PIC 정보</h3>
+
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="factoryPicName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이름 *</FormLabel>
+ <FormControl>
+ <Input placeholder="PIC 이름" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryPicPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호 *</FormLabel>
+ <FormControl>
+ <Input placeholder="전화번호" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryPicEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일 *</FormLabel>
+ <FormControl>
+ <Input placeholder="이메일 주소" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 공장 가는 법 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 가는 법</h3>
+
+ <FormField
+ control={form.control}
+ name="factoryDirections"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장 가는 법 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="공항에서 공장까지 가는 방법, 대중교통 정보 등을 상세히 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 공장 출입절차 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 출입절차</h3>
+
+ <FormField
+ control={form.control}
+ name="accessProcedure"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장 출입절차 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="신분증 제출, 출입증 교환, 준비물 등 출입 절차를 상세히 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+
+
+ {/* 첨부파일 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">첨부파일</h3>
+
+ {/* 파일 업로드 */}
+ <div className="space-y-2">
+ <FormLabel>파일 업로드</FormLabel>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center">
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
+ onChange={handleFileUpload}
+ className="hidden"
+ disabled={isPending}
+ />
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isPending}
+ className="w-full"
+ >
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 선택
+ </Button>
+ <p className="text-xs text-muted-foreground mt-2">
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+ </p>
+ </div>
+ </div>
+
+ {/* 첨부된 파일 목록 */}
+ <div>
+ <FormLabel>첨부된 파일</FormLabel>
+ <div className="space-y-2">
+ {selectedFiles.length > 0 ? (
+ selectedFiles.map((file, index) => (
+ <div key={index} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{file.name}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round(file.size / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ disabled={isPending}
+ className="text-destructive hover:text-destructive"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))
+ ) : (
+ <div className="text-sm text-muted-foreground text-center py-4">
+ 첨부된 파일이 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기타 정보</h3>
+
+ <FormField
+ control={form.control}
+ name="otherInfo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 정보 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가로 전달하고 싶은 정보가 있다면 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <SheetFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? "처리 중..." : "정보입력"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx
new file mode 100644
index 00000000..b9daf83e
--- /dev/null
+++ b/lib/site-visit/vendor-info-view-dialog.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { Building2, User, Phone, Mail, FileText, Calendar } from "lucide-react"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+
+interface VendorInfo {
+ id: number
+ siteVisitRequestId: number
+ factoryName: string
+ factoryLocation: string
+ factoryAddress: string
+ factoryPicName: string
+ factoryPicPhone: string
+ factoryPicEmail: string
+ factoryDirections: string | null
+ accessProcedure: string | null
+ hasAttachments: boolean
+ otherInfo: string | null
+ submittedAt: Date
+ submittedBy: number
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface Attachment {
+ id: number
+ siteVisitRequestId: number
+ vendorSiteVisitInfoId: number | null
+ fileName: string
+ originalFileName: string | null
+ filePath: string
+ fileSize: number | null
+ mimeType: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorInfoViewDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ siteVisitRequestId: number | null
+}
+
+export function VendorInfoViewDialog({
+ isOpen,
+ onClose,
+ siteVisitRequestId,
+}: VendorInfoViewDialogProps) {
+ const [data, setData] = React.useState<VendorInfo | null>(null)
+ const [attachments, setAttachments] = React.useState<Attachment[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 데이터 로드
+ React.useEffect(() => {
+ if (isOpen && siteVisitRequestId) {
+ loadVendorInfo()
+ }
+ }, [isOpen, siteVisitRequestId])
+
+ const loadVendorInfo = async () => {
+ if (!siteVisitRequestId) return
+
+ setIsLoading(true)
+ try {
+ const { getVendorSiteVisitInfoAction } = await import("./service")
+ const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
+
+ if (result.success && result.data) {
+ setData(result.data.vendorInfo)
+ setAttachments(result.data.attachments || [])
+ } else {
+ toast.error("협력업체 정보를 불러올 수 없습니다.")
+ }
+ } catch (error) {
+ console.error("협력업체 정보 로드 오류:", error)
+ toast.error("협력업체 정보를 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const formatDate = (date: Date | null) => {
+ if (!date) return "-"
+ return format(date, "yyyy.MM.dd", { locale: ko })
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>협력업체 방문실사 정보</DialogTitle>
+ <DialogDescription>
+ 협력업체가 입력한 방문실사 관련 정보를 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">협력업체 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ ) : data ? (
+ <div className="space-y-6">
+ {/* 협력업체 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Building2 className="h-5 w-5" />
+ 협력업체 공장 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-semibold mb-2">공장 기본 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">공장명:</span> {data.factoryName}</div>
+ <div><span className="font-medium">공장위치:</span> {data.factoryLocation}</div>
+ <div><span className="font-medium">공장주소:</span> {data.factoryAddress}</div>
+ </div>
+ </div>
+
+ <div>
+ <h4 className="font-semibold mb-2">공장 PIC 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ <span>{data.factoryPicName}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Phone className="h-4 w-4" />
+ <span>{data.factoryPicPhone}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Mail className="h-4 w-4" />
+ <span>{data.factoryPicEmail}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ {data.factoryDirections && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 가는 법</h4>
+ <div className="bg-muted p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{data.factoryDirections}</p>
+ </div>
+ </div>
+ )}
+
+ {data.accessProcedure && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 출입절차</h4>
+ <div className="bg-muted p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{data.accessProcedure}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 첨부파일 */}
+ {attachments.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 협력업체 첨부파일 ({attachments.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{attachment.originalFileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round((attachment.fileSize || 0) / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(attachment.filePath, attachment.originalFileName || '', {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ 다운로드
+ </Button>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 기타 정보 */}
+ {data.otherInfo && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 기타 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm whitespace-pre-wrap">{data.otherInfo}</p>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 제출 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 제출 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">제출일:</span> {formatDate(data.submittedAt)}</div>
+ <div><span className="font-medium">첨부파일:</span> {data.hasAttachments ? "있음" : "없음"}</div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ ) : (
+ <div className="text-center py-8">
+ <div className="text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>협력업체가 아직 정보를 입력하지 않았습니다.</p>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file