summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 06:20:56 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 06:20:56 +0000
commit2fc9e5492e220041ba322d9a1479feb7803228cf (patch)
treeda8ace07ed23ba92f2408c9c6e9ae2e31be20160 /lib
parent5202c4b56d9565c7ac0c2a62255763462cef0d3d (diff)
(최겸) 구매 PQ수정, 정규업체 결재 개발(진행중)
Diffstat (limited to 'lib')
-rw-r--r--lib/approval/handlers-registry.ts6
-rw-r--r--lib/pq/pq-criteria/pq-table.tsx2
-rw-r--r--lib/pq/service.ts13
-rw-r--r--lib/vendor-regular-registrations/approval-actions.ts99
-rw-r--r--lib/vendor-regular-registrations/handlers.ts180
-rw-r--r--lib/vendor-regular-registrations/service.ts10
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx119
-rw-r--r--lib/vendors/service.ts66
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx277
9 files changed, 628 insertions, 144 deletions
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index 1e8140e5..1db79974 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -30,7 +30,11 @@ export async function initializeApprovalHandlers() {
// const { createPurchaseOrderInternal } = await import('@/lib/purchase-order/handlers');
// registerActionHandler('purchase_order_request', createPurchaseOrderInternal);
- // 3. 계약 승인 핸들러
+ // 3. 정규업체 등록 핸들러
+ const { registerVendorInternal } = await import('@/lib/vendor-regular-registrations/handlers');
+ registerActionHandler('vendor_regular_registration', registerVendorInternal);
+
+ // 4. 계약 승인 핸들러
// const { approveContractInternal } = await import('@/lib/contract/handlers');
// registerActionHandler('contract_approval', approveContractInternal);
diff --git a/lib/pq/pq-criteria/pq-table.tsx b/lib/pq/pq-criteria/pq-table.tsx
index 187a727b..83c2daec 100644
--- a/lib/pq/pq-criteria/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 { getPQsByListId } from "../service"
+import { getPQsByListId, getPQListInfo } from "../service"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { PqCriterias } from "@/db/schema/pq"
import { DeletePqsDialog } from "./delete-pqs-dialog"
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 54459a6c..b6640453 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -495,7 +495,7 @@ export async function submitPQAction({
}
// 제출 가능한 상태 확인
- const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"];
+ const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "SUBMITTED", "REJECTED"];
if (existingSubmission) {
if (!allowedStatuses.includes(existingSubmission.status)) {
@@ -533,7 +533,6 @@ export async function submitPQAction({
submittedAt: currentDate,
createdAt: currentDate,
updatedAt: currentDate,
- requesterId: requesterId,
});
}
@@ -554,9 +553,9 @@ export async function submitPQAction({
// 5. PQ 요청자에게 이메일 알림 발송
const targetSubmissionId = existingSubmission?.id || '';
- const targetRequesterId = existingSubmission?.requesterId || requesterId;
+ const targetRequesterId = existingSubmission?.requesterId || null;
- if (targetRequesterId) {
+ if (targetRequesterId !== null) {
try {
// 요청자 정보 조회
const requester = await db
@@ -2886,6 +2885,7 @@ function getInvestigationMethodLabel(method: string): string {
export async function getQMManagers() {
try {
// domain이 'partners'가 아니고, isActive가 true인 사용자만 조회
+ // 또는 deptName이 '품질경영팀('를 포함하는 경우도 포함
const qmUsers = await db
.select({
id: users.id,
@@ -2897,7 +2897,7 @@ export async function getQMManagers() {
.where(
and(
eq(users.isActive, true),
- ne(users.domain, "partners")
+ ilike(users.deptName, "%품질경영팀(%")
)
)
.orderBy(users.name);
@@ -3770,6 +3770,9 @@ export async function deletePQSubmissionAction(pqSubmissionId: number) {
.where(eq(vendorPQSubmissions.id, pqSubmissionId));
});
+ // 삭제 후 캐시 무효화 (PQ 히스토리 캐시)
+ revalidateTag('pq-submissions');
+
return { success: true };
} catch (error) {
console.error("deletePQSubmissionAction error:", error);
diff --git a/lib/vendor-regular-registrations/approval-actions.ts b/lib/vendor-regular-registrations/approval-actions.ts
new file mode 100644
index 00000000..02c7e412
--- /dev/null
+++ b/lib/vendor-regular-registrations/approval-actions.ts
@@ -0,0 +1,99 @@
+/**
+ * 정규업체 등록 관련 결재 서버 액션
+ *
+ * 사용자가 UI에서 호출하는 함수들
+ * withApproval()을 사용하여 결재 프로세스를 시작
+ */
+
+'use server';
+
+import { withApproval } from '@/lib/approval/approval-workflow';
+import { mapRegistrationToTemplateVariables } from './handlers';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import type { RegistrationRequestData } from '@/components/vendor-regular-registrations/registration-request-dialog';
+
+/**
+ * 결재를 거쳐 정규업체 등록을 처리하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await registerVendorWithApproval({
+ * registrationId: 123,
+ * requestData: registrationData,
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003']
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function registerVendorWithApproval(data: {
+ registrationId: number;
+ requestData: RegistrationRequestData;
+ vendorId?: number; // vendors 테이블에서 정보를 가져오기 위한 vendorId
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+}) {
+ debugLog('[VendorRegistrationApproval] 정규업체 등록 결재 서버 액션 시작', {
+ registrationId: data.registrationId,
+ companyName: data.requestData.companyNameKor,
+ businessNumber: data.requestData.businessNumber,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[VendorRegistrationApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.registrationId) {
+ debugError('[VendorRegistrationApproval] 등록 ID 없음');
+ throw new Error('등록 ID가 필요합니다');
+ }
+
+ // 1. 템플릿 변수 매핑
+ debugLog('[VendorRegistrationApproval] 템플릿 변수 매핑 시작');
+ const requestedAt = new Date();
+ const variables = await mapRegistrationToTemplateVariables({
+ requestData: data.requestData,
+ requestedAt,
+ vendorId: data.vendorId,
+ });
+ debugLog('[VendorRegistrationApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 2. 결재 워크플로우 시작 (템플릿 기반)
+ debugLog('[VendorRegistrationApproval] withApproval 호출');
+ const result = await withApproval(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'vendor_regular_registration',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ registrationId: data.registrationId,
+ requestData: data.requestData,
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: `정규업체 등록 - ${data.requestData.companyNameKor}`,
+ description: `${data.requestData.companyNameKor} 정규업체 등록 요청`,
+ templateName: '정규업체 등록', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugSuccess('[VendorRegistrationApproval] 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ status: result.status,
+ });
+
+ return result;
+}
diff --git a/lib/vendor-regular-registrations/handlers.ts b/lib/vendor-regular-registrations/handlers.ts
new file mode 100644
index 00000000..4b21263d
--- /dev/null
+++ b/lib/vendor-regular-registrations/handlers.ts
@@ -0,0 +1,180 @@
+/**
+ * 정규업체 등록 관련 결재 액션 핸들러
+ *
+ * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리)
+ */
+
+'use server';
+
+import { submitRegistrationRequest } from './service';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import type { RegistrationRequestData } from '@/components/vendor-regular-registrations/registration-request-dialog';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { vendors } from '@/db/schema/vendors';
+import { vendorAdditionalInfo } from '@/db/schema/vendorRegistrations';
+
+/**
+ * 정규업체 등록 핸들러 (결재 승인 후 실행됨)
+ *
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function registerVendorInternal(payload: {
+ registrationId: number;
+ requestData: RegistrationRequestData;
+}) {
+ debugLog('[VendorRegistrationHandler] 정규업체 등록 핸들러 시작', {
+ registrationId: payload.registrationId,
+ companyName: payload.requestData.companyNameKor,
+ });
+
+ try {
+ // 실제 정규업체 등록 처리
+ debugLog('[VendorRegistrationHandler] submitRegistrationRequest 호출');
+ const result = await submitRegistrationRequest(
+ payload.registrationId,
+ payload.requestData
+ );
+
+ if (!result.success) {
+ debugError('[VendorRegistrationHandler] 정규업체 등록 실패', result.error);
+ throw new Error(result.error || '정규업체 등록에 실패했습니다.');
+ }
+
+ debugSuccess('[VendorRegistrationHandler] 정규업체 등록 완료', {
+ registrationId: payload.registrationId,
+ });
+
+ return {
+ success: true,
+ message: '정규업체 등록이 완료되었습니다.',
+ };
+ } catch (error) {
+ debugError('[VendorRegistrationHandler] 정규업체 등록 중 에러', error);
+ throw error;
+ }
+}
+
+/**
+ * 정규업체 등록 데이터를 결재 템플릿 변수로 매핑
+ *
+ * 제공된 HTML 템플릿의 변수명에 맞춰 매핑
+ *
+ * @param payload - 정규업체 등록 데이터
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapRegistrationToTemplateVariables(payload: {
+ requestData: RegistrationRequestData;
+ requestedAt: Date;
+ vendorId?: number; // vendors 테이블에서 정보를 가져오기 위한 vendorId
+}): Promise<Record<string, string>> {
+ const { requestData, requestedAt, vendorId } = payload;
+
+ // vendors 테이블에서 추가 정보 가져오기
+ let vendorInfo: any = {};
+ if (vendorId) {
+ try {
+ const vendorResult = await db
+ .select({
+ postalCode: vendors.postalCode,
+ businessSize: vendors.businessSize,
+ addressDetail: vendors.addressDetail,
+ // FAX, 사업유형, 산업유형은 vendors 테이블에 없으므로 빈 값으로 처리
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1);
+
+ vendorInfo = vendorResult[0] || {};
+ } catch (error) {
+ console.warn('[Template Variables] Failed to fetch vendor info:', error);
+ }
+ }
+ // 추가정보 조회
+ let additionalInfo = {
+ businessType: '',
+ industryType: '',
+ companySize: '',
+ revenue: '',
+ factoryEstablishedDate: '',
+ preferredContractTerms: '',
+ };
+
+ if (vendorId) {
+ const additionalInfoResult = await db
+ .select({
+ businessType: vendorAdditionalInfo.businessType,
+ industryType: vendorAdditionalInfo.industryType,
+ companySize: vendorAdditionalInfo.companySize,
+ revenue: vendorAdditionalInfo.revenue,
+ factoryEstablishedDate: vendorAdditionalInfo.factoryEstablishedDate,
+ preferredContractTerms: vendorAdditionalInfo.preferredContractTerms,
+ })
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1);
+
+ additionalInfo = additionalInfoResult[0] || additionalInfo;
+ }
+
+ console.log('[Template Variables] Additional info:', additionalInfo);
+ const variables = {
+ // 협력업체 기본정보 (템플릿의 정확한 변수명 사용)
+ ' 협력업체 기본정보-사업자번호 ': requestData.businessNumber || '',
+ ' 협력업체 기본정보-업체명 ': requestData.companyNameKor || '',
+ ' 협력업체 기본정보-대표자명 ': requestData.representativeNameKor || '',
+ ' 협력업체 기본정보 대표전화 ': requestData.headOfficePhone || '',
+ ' 협력업체 기본정보 -FAX ': '', // FAX 정보는 vendors 테이블에 없으므로 빈 문자열
+ ' 협력업체 기본정보 -E-mail ': requestData.representativeEmail || '',
+ ' 협력업체 기본정보-우편번호 ': vendorInfo.postalCode || '', // vendors 테이블에서 우편번호 가져오기
+ ' 협력업체 기본정보-회사주소': requestData.headOfficeAddress || '',
+ ' 협력업체 기본정보-상세주소': vendorInfo.addressDetail || '', // 상세주소는 벤더 상세주소로
+ ' 협력업체 기본정보-사업유형': additionalInfo.businessType || '', // 주요품목을 사업유형으로 사용
+ ' 협력업체 기본정보-산업유형': additionalInfo.industryType || '', // 주요품목을 산업유형으로도 사용
+ ' 협력업체 기본정보-회사규모': additionalInfo.companySize || '', // 기업규모
+
+ // 담당자 연락처 (각 담당자별로 동일한 정보 반복 - 템플릿에서 여러 번 사용됨)
+ ' 협력업체 관리-상세보기-영업담당자-담당자명 ': requestData.businessContacts.sales.name || '',
+ ' 협력업체 관리-상세보기-영업담당자-직급 ': requestData.businessContacts.sales.position || '',
+ ' 협력업체 관리-상세보기-영업담당자-부서 ': requestData.businessContacts.sales.department || '',
+ ' 협력업체 관리-상세보기-영업담당자-담당업무 ': requestData.businessContacts.sales.responsibility || '',
+ ' 협력업체 관리-상세보기-영업담당자-이메일 ': requestData.businessContacts.sales.email || '',
+ ' 협력업체 관리-상세보기-설계담당자-담당자명 ': requestData.businessContacts.design.name || '',
+ ' 협력업체 관리-상세보기-설계담당자-직급 ': requestData.businessContacts.design.position || '',
+ ' 협력업체 관리-상세보기-설계담당자-부서 ': requestData.businessContacts.design.department || '',
+ ' 협력업체 관리-상세보기-설계담당자-담당업무 ': requestData.businessContacts.design.responsibility || '',
+ ' 협력업체 관리-상세보기-설계담당자-이메일 ': requestData.businessContacts.design.email || '',
+ ' 협력업체 관리-상세보기-납기담당자-담당자명 ': requestData.businessContacts.delivery.name || '',
+ ' 협력업체 관리-상세보기-납기담당자-직급 ': requestData.businessContacts.delivery.position || '',
+ ' 협력업체 관리-상세보기-납기담당자-부서 ': requestData.businessContacts.delivery.department || '',
+ ' 협력업체 관리-상세보기-납기담당자-담당업무 ': requestData.businessContacts.delivery.responsibility || '',
+ ' 협력업체 관리-상세보기-납기담당자-이메일 ': requestData.businessContacts.delivery.email || '',
+ ' 협력업체 관리-상세보기-품질담당자-담당자명 ': requestData.businessContacts.quality.name || '',
+ ' 협력업체 관리-상세보기-품질담당자-직급 ': requestData.businessContacts.quality.position || '',
+ ' 협력업체 관리-상세보기-품질담당자-부서 ': requestData.businessContacts.quality.department || '',
+ ' 협력업체 관리-상세보기-품질담당자-담당업무 ': requestData.businessContacts.quality.responsibility || '',
+ ' 협력업체 관리-상세보기-품질담당자-이메일 ': requestData.businessContacts.quality.email || '',
+ ' 협력업체 관리-상세보기-세금계산서담당자-담당자명 ': requestData.businessContacts.taxInvoice.name || '',
+ ' 협력업체 관리-상세보기-세금계산서담당자-직급 ': requestData.businessContacts.taxInvoice.position || '',
+ ' 협력업체 관리-상세보기-세금계산서담당자-부서 ': requestData.businessContacts.taxInvoice.department || '',
+ ' 협력업체 관리-상세보기-세금계산서담당자-담당업무 ': requestData.businessContacts.taxInvoice.responsibility || '',
+ ' 협력업체 관리-상세보기-세금계산서담당자-이메일 ': requestData.businessContacts.taxInvoice.email || '',
+
+ // 기본계약서 현황 (정규업체 등록 시점에는 아직 계약서가 없으므로 빈 값)
+ '정규업체등록관리-문서현황-계약동의현황-계약유형 ': '정규업체 등록 요청',
+ '정규업체등록관리-문서현황-계약동의현황-상태 ': '등록 대기',
+ '정규업체등록관리-문서현황-계약동의현황-서약일자 ': new Date(requestedAt).toLocaleDateString('ko-KR'),
+ };
+
+ // 디버깅을 위한 로그 출력
+ console.log('[Template Variables] Generated variables:', Object.keys(variables));
+ console.log('[Template Variables] Sample values:', {
+ companyName: variables[' 협력업체 기본정보-업체명 '],
+ businessNumber: variables[' 협력업체 기본정보-사업자번호 '],
+ representative: variables[' 협력업체 기본정보-대표자명 '],
+ });
+
+ return variables;
+}
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
index e163b147..372212fc 100644
--- a/lib/vendor-regular-registrations/service.ts
+++ b/lib/vendor-regular-registrations/service.ts
@@ -1238,11 +1238,8 @@ export async function submitRegistrationRequest(
console.log('✅ MDG 송신 성공:', mdgResult.message);
}
- // TODO: Knox 결재 연동
- // - 사업자등록증, 신용평가보고서, 개인정보동의서, 통장사본
- // - 실사결과 보고서
- // - CP문서, GTC문서, 비밀유지계약서
- // await initiateKnoxApproval(registrationRequestData);
+ // Knox 결재 연동은 별도의 결재 워크플로우에서 처리됩니다.
+ // UI에서 registerVendorWithApproval()을 호출하여 결재 프로세스를 시작합니다.
console.log("✅ 정규업체 등록 요청 데이터:", {
registrationId,
@@ -1260,7 +1257,7 @@ export async function submitRegistrationRequest(
return {
success: true,
- message: `정규업체 등록 요청이 성공적으로 제출되었습니다.\n${mdgResult.success ? 'MDG 인터페이스 연동이 완료되었습니다.' : 'MDG 인터페이스 연동에 실패했습니다. (재시도 가능)'}\nKnox 결재 시스템 연동은 추후 구현 예정입니다.`
+ message: `정규업체 등록 요청이 성공적으로 제출되었습니다.\n${mdgResult.success ? 'MDG 인터페이스 연동이 완료되었습니다.' : 'MDG 인터페이스 연동에 실패했습니다. (재시도 가능)'}\n결재 승인 후 정규업체 등록이 완료됩니다.`
};
} catch (error) {
@@ -1406,6 +1403,7 @@ export async function sendRegistrationRequestToMDG(
};
// MDG로 데이터 전송
+ console.log('📤 MDG 송신 데이터:', mdgData);
const result = await sendTestVendorDataToMDG(mdgData);
console.log('📤 MDG 송신 결과:', result);
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
index df2ab53a..d88cd7b7 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
@@ -15,7 +15,12 @@ import {
import { useState } from "react"
import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog"
import { RegistrationRequestDialog } from "@/components/vendor-regular-registrations/registration-request-dialog"
+import { ApprovalPreviewDialog } from "@/components/approval/ApprovalPreviewDialog"
import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import { registerVendorWithApproval } from "../approval-actions"
+import { mapRegistrationToTemplateVariables } from "../handlers"
+import type { RegistrationRequestData } from "@/components/vendor-regular-registrations/registration-request-dialog"
interface VendorRegularRegistrationsTableToolbarActionsProps {
table: Table<VendorRegularRegistration>
@@ -25,6 +30,8 @@ export function VendorRegularRegistrationsTableToolbarActions({
table,
}: VendorRegularRegistrationsTableToolbarActionsProps) {
const router = useRouter()
+ const { data: session } = useSession()
+
const [syncLoading, setSyncLoading] = useState<{
missingContract: boolean;
additionalInfo: boolean;
@@ -43,6 +50,7 @@ export function VendorRegularRegistrationsTableToolbarActions({
legalReview: false,
})
+ // 2-step 결재 프로세스를 위한 상태
const [registrationRequestDialog, setRegistrationRequestDialog] = useState<{
open: boolean;
registration: VendorRegularRegistration | null;
@@ -51,6 +59,18 @@ export function VendorRegularRegistrationsTableToolbarActions({
registration: null,
})
+ const [approvalDialog, setApprovalDialog] = useState<{
+ open: boolean;
+ registration: VendorRegularRegistration | null;
+ }>({
+ open: false,
+ registration: null,
+ })
+
+ // 결재를 위한 중간 상태 저장
+ const [registrationFormData, setRegistrationFormData] = useState<RegistrationRequestData | null>(null)
+ const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
+
const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
@@ -129,7 +149,7 @@ export function VendorRegularRegistrationsTableToolbarActions({
}
};
- // 등록요청 핸들러
+ // 등록요청 핸들러 - Step 1: 정보 입력
const handleRegistrationRequest = () => {
const approvalReadyRows = selectedRows.filter(row => row.status === "approval_ready");
@@ -149,22 +169,73 @@ export function VendorRegularRegistrationsTableToolbarActions({
});
};
- const handleRegistrationRequestSubmit = async (requestData: any) => {
- if (!registrationRequestDialog.registration) return;
+ // 등록요청 정보 입력 완료 - Step 1에서 Step 2로 전환
+ const handleRegistrationRequestSubmit = async (requestData: RegistrationRequestData) => {
+ if (!registrationRequestDialog.registration || !session?.user) return;
+
+ try {
+ // 폼 데이터 저장
+ setRegistrationFormData(requestData);
+
+ // 결재 템플릿 변수 생성
+ const requestedAt = new Date();
+ const variables = await mapRegistrationToTemplateVariables({
+ requestData,
+ requestedAt,
+ });
+
+ setApprovalVariables(variables);
+
+ // RegistrationRequestDialog 닫고 ApprovalPreviewDialog 열기
+ setRegistrationRequestDialog({ open: false, registration: null });
+ setApprovalDialog({
+ open: true,
+ registration: registrationRequestDialog.registration,
+ });
+ } catch (error) {
+ console.error("결재 준비 중 오류 발생:", error);
+ toast.error("결재 준비 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 결재 상신 - Step 2: 결재선 선택 후 최종 상신
+ const handleApprovalSubmit = async (approvers: any[]) => {
+ if (!approvalDialog.registration || !registrationFormData || !session?.user) {
+ toast.error("세션 정보가 없습니다.");
+ return;
+ }
setSyncLoading(prev => ({ ...prev, registrationRequest: true }));
try {
- const result = await submitRegistrationRequest(registrationRequestDialog.registration.id, requestData);
- if (result.success) {
- toast.success(result.message);
- setRegistrationRequestDialog({ open: false, registration: null });
+ // 결재선에서 EP ID 추출 (상신자 제외)
+ const approverEpIds = approvers
+ .filter((line) => line.seq !== "0" && line.epId)
+ .map((line) => line.epId!);
+
+ // 결재 워크플로우 시작
+ const result = await registerVendorWithApproval({
+ registrationId: approvalDialog.registration.id,
+ requestData: registrationFormData,
+ vendorId: approvalDialog.registration.vendorId, // vendors 테이블에서 정보를 가져오기 위한 vendorId
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ email: session.user.email || undefined,
+ },
+ approvers: approverEpIds,
+ });
+
+ if (result.status === 'pending_approval') {
+ // 성공 시에만 상태 초기화 및 페이지 리로드
+ setRegistrationFormData(null);
+ setApprovalVariables({});
+ setApprovalDialog({ open: false, registration: null });
+ toast.success("정규업체 등록 결재가 상신되었습니다.");
router.refresh();
- } else {
- toast.error(result.error);
}
} catch (error) {
- console.error("등록요청 오류:", error);
- toast.error("등록요청 중 오류가 발생했습니다.");
+ console.error("결재 상신 중 오류:", error);
+ toast.error("결재 상신 중 오류가 발생했습니다.");
} finally {
setSyncLoading(prev => ({ ...prev, registrationRequest: false }));
}
@@ -233,6 +304,32 @@ export function VendorRegularRegistrationsTableToolbarActions({
registration={registrationRequestDialog.registration}
onSubmit={handleRegistrationRequestSubmit}
/>
+
+ {/* 결재 미리보기 Dialog - 정규업체 등록 */}
+ {session?.user && approvalDialog.registration && (
+ <ApprovalPreviewDialog
+ open={approvalDialog.open}
+ onOpenChange={(open) => {
+ setApprovalDialog(prev => ({ ...prev, open }));
+ if (!open) {
+ // 다이얼로그가 닫히면 폼 데이터도 초기화
+ setRegistrationFormData(null);
+ setApprovalVariables({});
+ }
+ }}
+ templateName="정규업체 등록"
+ variables={approvalVariables}
+ title={`정규업체 등록 - ${approvalDialog.registration.companyName}`}
+ description={`${approvalDialog.registration.companyName} 정규업체 등록 요청`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ name: session.user.name || null,
+ email: session.user.email || '',
+ }}
+ onSubmit={handleApprovalSubmit}
+ />
+ )}
</div>
)
}
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 0c61c270..76193eb9 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -1983,6 +1983,72 @@ export async function rejectVendors(input: ApproveVendorsInput & { userId: numbe
*
* 예: PQ-240520-00001, PQ-240520-00002, ...
*/
+// 벤더의 연락처 조회 (간단 버전)
+// 추후 Pq요청 내 업체담당자 선택 기능
+export async function getVendorContactsSimple(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const contacts = await db
+ .select({
+ id: vendorContacts.id,
+ contactName: vendorContacts.contactName,
+ contactPosition: vendorContacts.contactPosition,
+ contactEmail: vendorContacts.contactEmail,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ createdAt: vendorContacts.createdAt,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(desc(vendorContacts.isPrimary), desc(vendorContacts.createdAt));
+
+ return { success: true, data: contacts };
+ } catch (error) {
+ logger.error('벤더 연락처 조회 실패:', error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+ },
+ [`vendor-contacts-simple-${vendorId}`],
+ { revalidate: 300 } // 5분 캐시
+ )();
+}
+
+// 벤더의 PQ 히스토리 조회
+export async function getVendorPQHistory(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const pqHistory = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ pqNumber: vendorPQSubmissions.pqNumber,
+ type: vendorPQSubmissions.type,
+ projectId: vendorPQSubmissions.projectId,
+ status: vendorPQSubmissions.status,
+ dueDate: vendorPQSubmissions.dueDate,
+ createdAt: vendorPQSubmissions.createdAt,
+ updatedAt: vendorPQSubmissions.updatedAt,
+ projectCode: projects.code,
+ projectName: projects.name,
+ pqItems: vendorPQSubmissions.pqItems,
+ })
+ .from(vendorPQSubmissions)
+ .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id))
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(desc(vendorPQSubmissions.createdAt));
+
+ return { success: true, data: pqHistory };
+ } catch (error) {
+ logger.error('벤더 PQ 히스토리 조회 실패:', error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+ },
+ [`vendor-pq-history-${vendorId}`],
+ { revalidate: 300 } // 5분 캐시
+ )();
+}
+
export async function generatePQNumber(isProject: boolean = false) {
try {
// 현재 날짜 가져오기
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 2f39cae1..07057dbe 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -46,10 +46,11 @@ import { useSession } from "next-auth/react"
import { DatePicker } from "@/components/ui/date-picker"
import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
import type { BasicContractTemplate } from "@/db/schema"
-import { searchItemsForPQ } from "@/lib/items/service"
-import { saveNdaAttachments } from "../service"
+import { saveNdaAttachments, getVendorPQHistory } from "../service"
import { useRouter } from "next/navigation"
import { createGtcVendorDocuments, createProjectGtcVendorDocuments, getStandardGtcDocumentId, getProjectGtcDocumentId } from "@/lib/gtc-contract/service"
+import { MaterialGroupSelectorDialogMulti } from "@/components/common/material/material-group-selector-dialog-multi"
+import type { MaterialSearchItem } from "@/lib/material/material-group-service"
// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -69,11 +70,7 @@ interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dia
// "GTC 합의",
// ]
-// PQ 대상 품목 타입 정의
-interface PQItem {
- itemCode: string
- itemName: string
-}
+// PQ 대상 품목 타입 정의 (Material Group 기반) - MaterialSearchItem 사용
export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...props }: RequestPQDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
@@ -86,12 +83,9 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
const [agreements, setAgreements] = React.useState<Record<string, boolean>>({})
const [extraNote, setExtraNote] = React.useState<string>("")
- const [pqItems, setPqItems] = React.useState<PQItem[]>([])
+ const [pqItems, setPqItems] = React.useState<MaterialSearchItem[]>([])
- // 아이템 검색 관련 상태
- const [itemSearchQuery, setItemSearchQuery] = React.useState<string>("")
- const [filteredItems, setFilteredItems] = React.useState<PQItem[]>([])
- const [showItemDropdown, setShowItemDropdown] = React.useState<boolean>(false)
+ // PQ 품목 선택 관련 상태는 MaterialGroupSelectorDialogMulti에서 관리됨
const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
@@ -106,31 +100,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
const [currentStep, setCurrentStep] = React.useState("")
const [showProgress, setShowProgress] = React.useState(false)
- // 아이템 검색 필터링
- React.useEffect(() => {
- if (itemSearchQuery.trim() === "") {
- setFilteredItems([])
- setShowItemDropdown(false)
- return
- }
+ // PQ 히스토리 관련 상태
+ const [pqHistory, setPqHistory] = React.useState<Record<number, any[]>>({})
+ const [isLoadingHistory, setIsLoadingHistory] = React.useState(false)
- const searchItems = async () => {
- try {
- const results = await searchItemsForPQ(itemSearchQuery)
- setFilteredItems(results)
- setShowItemDropdown(true)
- } catch (error) {
- console.error("아이템 검색 오류:", error)
- toast.error("아이템 검색 중 오류가 발생했습니다.")
- setFilteredItems([])
- setShowItemDropdown(false)
- }
- }
-
- // 디바운싱: 300ms 후에 검색 실행
- const timeoutId = setTimeout(searchItems, 300)
- return () => clearTimeout(timeoutId)
- }, [itemSearchQuery])
React.useEffect(() => {
if (type === "PROJECT") {
@@ -140,9 +113,37 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
}
}, [type])
- // 기본계약서 템플릿 로딩 및 자동 선택
+ // 기본계약서 템플릿 로딩 및 자동 선택, PQ 히스토리 로딩
React.useEffect(() => {
setIsLoadingTemplates(true)
+ const loadPQHistory = async () => {
+ if (vendors.length === 0) return
+
+ setIsLoadingHistory(true)
+ try {
+ const historyPromises = vendors.map(async (vendor) => {
+ console.log("vendor.id", vendor.id)
+ const result = await getVendorPQHistory(vendor.id)
+ console.log("result", result)
+ return { vendorId: vendor.id, history: result.success ? result.data : [] }
+ })
+
+ const results = await Promise.all(historyPromises)
+ const historyMap: Record<number, any[]> = {}
+
+ results.forEach(({ vendorId, history }) => {
+ historyMap[vendorId] = history
+ })
+
+ setPqHistory(historyMap)
+ } catch (error) {
+ console.error('PQ 히스토리 로딩 실패:', error)
+ toast.error('PQ 히스토리 로딩 중 오류가 발생했습니다')
+ } finally {
+ setIsLoadingHistory(false)
+ }
+ }
+ loadPQHistory()
getALLBasicContractTemplates()
.then((templates) => {
@@ -213,37 +214,24 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
setPqItems([])
setExtraNote("")
setSelectedTemplateIds([])
- setItemSearchQuery("")
- setFilteredItems([])
- setShowItemDropdown(false)
setNdaAttachments([])
setIsUploadingNdaFiles(false)
setProgressValue(0)
setCurrentStep("")
setShowProgress(false)
+ setPqHistory({})
+ setIsLoadingHistory(false)
}
}, [props.open])
- // 아이템 선택 함수
- const handleSelectItem = (item: PQItem) => {
- // 이미 선택된 아이템인지 확인
- const isAlreadySelected = pqItems.some(selectedItem =>
- selectedItem.itemCode === item.itemCode
- )
-
- if (!isAlreadySelected) {
- setPqItems(prev => [...prev, item])
- }
-
- // 검색 초기화
- setItemSearchQuery("")
- setFilteredItems([])
- setShowItemDropdown(false)
+ // PQ 품목 선택 함수 (MaterialGroupSelectorDialogMulti에서 호출됨)
+ const handlePQItemsChange = (items: MaterialSearchItem[]) => {
+ setPqItems(items)
}
- // 아이템 제거 함수
- const handleRemoveItem = (itemCode: string) => {
- setPqItems(prev => prev.filter(item => item.itemCode !== itemCode))
+ // PQ 품목 제거 함수
+ const handleRemovePQItem = (materialGroupCode: string) => {
+ setPqItems(prev => prev.filter(item => item.materialGroupCode !== materialGroupCode))
}
// 비밀유지 계약서 첨부파일 추가 함수
@@ -274,6 +262,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
if (!type) return toast.error("PQ 유형을 선택하세요.")
if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
if (!dueDate) return toast.error("마감일을 선택하세요.")
+ if (pqItems.length === 0) return toast.error("PQ 대상 품목을 선택하세요.")
if (!session?.user?.id) return toast.error("인증 실패")
// GTC 템플릿 선택 검증
@@ -317,7 +306,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
projectId: type === "PROJECT" ? selectedProjectId : null,
type: type || "GENERAL",
extraNote,
- pqItems: JSON.stringify(pqItems),
+ pqItems: JSON.stringify(pqItems.map(item => ({
+ materialGroupCode: item.materialGroupCode,
+ materialGroupDescription: item.materialGroupDescription
+ }))),
templateId: selectedTemplateIds.length > 0 ? selectedTemplateIds[0] : null,
})
@@ -660,9 +652,97 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
toast.error(`기본계약서 이메일 발송 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
}
}
-
-
+ // PQ 히스토리 컴포넌트
+ const PQHistorySection = () => {
+ if (isLoadingHistory) {
+ return (
+ <div className="px-4 py-3 border-b bg-muted/30">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader className="h-4 w-4 animate-spin" />
+ PQ 히스토리 로딩 중...
+ </div>
+ </div>
+ )
+ }
+
+ const hasAnyHistory = Object.values(pqHistory).some(history => history.length > 0)
+
+ if (!hasAnyHistory) {
+ return (
+ <div className="px-4 py-3 border-b bg-muted/30">
+ <div className="text-sm text-muted-foreground">
+ 최근 PQ 요청 내역이 없습니다.
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="px-4 py-3 border-b bg-muted/30 max-h-48 overflow-y-auto">
+ <div className="space-y-3">
+ <div className="text-sm font-medium text-muted-foreground">
+ 최근 PQ 요청 내역
+ </div>
+ {vendors.map((vendor) => {
+ const vendorHistory = pqHistory[vendor.id] || []
+ if (vendorHistory.length === 0) return null
+
+ return (
+ <div key={vendor.id} className="space-y-2">
+ <div className="text-xs font-medium text-muted-foreground border-b pb-1">
+ {vendor.vendorName}
+ </div>
+ <div className="space-y-1">
+ {vendorHistory.slice(0, 3).map((pq) => {
+ const createdDate = new Date(pq.createdAt).toLocaleDateString('ko-KR')
+ const statusText =
+ pq.status === 'REQUESTED' ? '요청됨' :
+ pq.status === 'APPROVED' ? '승인됨' :
+ pq.status === 'SUBMITTED' ? '제출됨' :
+ pq.status === 'REJECTED' ? '거절됨' :
+ pq.status
+
+ return (
+ <div key={pq.id} className="flex items-center justify-between text-xs bg-background rounded px-2 py-1">
+ <div className="flex items-center gap-2 flex-1">
+ <button
+ type="button"
+ onClick={() => router.push(`/evcp/pq_new?search=${encodeURIComponent(pq.pqNumber)}`)}
+ className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
+ >
+ {pq.pqNumber}
+ </button>
+ <Badge variant={pq.status === 'SUBMITTED' ? 'default' : pq.status === 'COMPLETED' ? 'default' : 'outline'} className="text-xs">
+ {statusText}
+ </Badge>
+ </div>
+ <div className="text-right">
+ <div className="text-muted-foreground">
+ {pq.type === 'GENERAL' ? '일반' : pq.type === 'PROJECT' ? '프로젝트' : '미실사'}
+ </div>
+ <div className="text-muted-foreground text-xs">
+ {createdDate}
+ </div>
+ </div>
+ </div>
+ )
+ })}
+ {vendorHistory.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ 외 {vendorHistory.length - 3}건 더 있음
+ </div>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )
+ }
+
+
const dialogContent = (
<div className="space-y-4 py-2">
@@ -734,68 +814,23 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
{/* PQ 대상품목 */}
<div className="space-y-2">
- <Label>PQ 대상품목</Label>
-
- {/* 선택된 아이템들 표시 */}
+ <Label>PQ 대상품목 *</Label>
+ <br />
+ <MaterialGroupSelectorDialogMulti
+ triggerLabel="자재 그룹 선택"
+ selectedMaterials={pqItems}
+ onMaterialsSelect={handlePQItemsChange}
+ maxSelections={10}
+ placeholder="PQ 대상 자재 그룹을 검색하세요"
+ title="PQ 대상 자재 그룹 선택"
+ description="PQ를 요청할 자재 그룹을 선택해주세요."
+ />
+
{pqItems.length > 0 && (
- <div className="flex flex-wrap gap-2 mb-2">
- {pqItems.map((item) => (
- <Badge key={item.itemCode} variant="secondary" className="flex items-center gap-1">
- <span className="text-xs">
- {item.itemCode} - {item.itemName}
- </span>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
- onClick={() => handleRemoveItem(item.itemCode)}
- >
- <X className="h-3 w-3" />
- </Button>
- </Badge>
- ))}
+ <div className="text-xs text-muted-foreground">
+ {pqItems.length}개 자재 그룹이 선택되었습니다.
</div>
)}
-
- {/* 검색 입력 */}
- <div className="relative">
- <div className="relative">
- <Input
- placeholder="아이템 코드 또는 이름으로 검색하세요"
- value={itemSearchQuery}
- onChange={(e) => setItemSearchQuery(e.target.value)}
- className="pl-9"
- />
- </div>
-
- {/* 검색 결과 드롭다운 */}
- {showItemDropdown && (
- <div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-background border rounded-md shadow-lg">
- {filteredItems.length > 0 ? (
- filteredItems.map((item) => (
- <button
- key={item.itemCode}
- type="button"
- className="w-full px-3 py-2 text-left text-sm hover:bg-muted focus:bg-muted focus:outline-none"
- onClick={() => handleSelectItem(item)}
- >
- <div className="font-medium">{item.itemCode}</div>
- <div className="text-muted-foreground text-xs">{item.itemName}</div>
- </button>
- ))
- ) : (
- <div className="px-3 py-2 text-sm text-muted-foreground">
- 검색 결과가 없습니다.
- </div>
- )}
- </div>
- )}
- </div>
-
- <div className="text-xs text-muted-foreground">
- 아이템 코드나 이름을 입력하여 검색하고 선택하세요. (선택사항)
- </div>
</div>
{/* 추가 안내사항 */}
@@ -957,6 +992,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
{vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
</DialogDescription>
</DialogHeader>
+ <PQHistorySection />
<div className="flex-1 overflow-y-auto">
{dialogContent}
</div>
@@ -1010,6 +1046,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
{vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
</DrawerDescription>
</DrawerHeader>
+ <PQHistorySection />
<div className="flex-1 overflow-y-auto px-4">
{dialogContent}
</div>