From 2fc9e5492e220041ba322d9a1479feb7803228cf Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 29 Oct 2025 06:20:56 +0000 Subject: (최겸) 구매 PQ수정, 정규업체 결재 개발(진행중) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/approval/handlers-registry.ts | 6 +- lib/pq/pq-criteria/pq-table.tsx | 2 +- lib/pq/service.ts | 13 +- .../approval-actions.ts | 99 ++++++++ lib/vendor-regular-registrations/handlers.ts | 180 +++++++++++++ lib/vendor-regular-registrations/service.ts | 10 +- ...regular-registrations-table-toolbar-actions.tsx | 119 ++++++++- lib/vendors/service.ts | 66 +++++ lib/vendors/table/request-pq-dialog.tsx | 277 ++++++++++++--------- 9 files changed, 628 insertions(+), 144 deletions(-) create mode 100644 lib/vendor-regular-registrations/approval-actions.ts create mode 100644 lib/vendor-regular-registrations/handlers.ts (limited to 'lib') 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) + */ +export async function mapRegistrationToTemplateVariables(payload: { + requestData: RegistrationRequestData; + requestedAt: Date; + vendorId?: number; // vendors 테이블에서 정보를 가져오기 위한 vendorId +}): Promise> { + 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 @@ -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(null) + const [approvalVariables, setApprovalVariables] = useState>({}) + 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 && ( + { + 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} + /> + )} ) } 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 { @@ -69,11 +70,7 @@ interface RequestPQDialogProps extends React.ComponentPropsWithoutRef(null) const [agreements, setAgreements] = React.useState>({}) const [extraNote, setExtraNote] = React.useState("") - const [pqItems, setPqItems] = React.useState([]) + const [pqItems, setPqItems] = React.useState([]) - // 아이템 검색 관련 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [filteredItems, setFilteredItems] = React.useState([]) - const [showItemDropdown, setShowItemDropdown] = React.useState(false) + // PQ 품목 선택 관련 상태는 MaterialGroupSelectorDialogMulti에서 관리됨 const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) const [basicContractTemplates, setBasicContractTemplates] = React.useState([]) const [selectedTemplateIds, setSelectedTemplateIds] = React.useState([]) @@ -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>({}) + 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 = {} + + 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 ( +
+
+ + PQ 히스토리 로딩 중... +
+
+ ) + } + + const hasAnyHistory = Object.values(pqHistory).some(history => history.length > 0) + + if (!hasAnyHistory) { + return ( +
+
+ 최근 PQ 요청 내역이 없습니다. +
+
+ ) + } + + return ( +
+
+
+ 최근 PQ 요청 내역 +
+ {vendors.map((vendor) => { + const vendorHistory = pqHistory[vendor.id] || [] + if (vendorHistory.length === 0) return null + + return ( +
+
+ {vendor.vendorName} +
+
+ {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 ( +
+
+ + + {statusText} + +
+
+
+ {pq.type === 'GENERAL' ? '일반' : pq.type === 'PROJECT' ? '프로젝트' : '미실사'} +
+
+ {createdDate} +
+
+
+ ) + })} + {vendorHistory.length > 3 && ( +
+ 외 {vendorHistory.length - 3}건 더 있음 +
+ )} +
+
+ ) + })} +
+
+ ) + } + + const dialogContent = (
@@ -734,68 +814,23 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro {/* PQ 대상품목 */}
- - - {/* 선택된 아이템들 표시 */} + +
+ + {pqItems.length > 0 && ( -
- {pqItems.map((item) => ( - - - {item.itemCode} - {item.itemName} - - - - ))} +
+ {pqItems.length}개 자재 그룹이 선택되었습니다.
)} - - {/* 검색 입력 */} -
-
- setItemSearchQuery(e.target.value)} - className="pl-9" - /> -
- - {/* 검색 결과 드롭다운 */} - {showItemDropdown && ( -
- {filteredItems.length > 0 ? ( - filteredItems.map((item) => ( - - )) - ) : ( -
- 검색 결과가 없습니다. -
- )} -
- )} -
- -
- 아이템 코드나 이름을 입력하여 검색하고 선택하세요. (선택사항) -
{/* 추가 안내사항 */} @@ -957,6 +992,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다. +
{dialogContent}
@@ -1010,6 +1046,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다. +
{dialogContent}
-- cgit v1.2.3