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