From 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 27 Aug 2025 12:06:26 +0000 Subject: (대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/repository.ts | 66 +- lib/basic-contract/service.ts | 1319 ++++++++++++++++- ...basic-contract-detail-table-toolbar-actions.tsx | 622 ++++++++ .../basic-contracts-detail-columns.tsx | 418 ++++++ .../status-detail/basic-contracts-detail-table.tsx | 90 ++ .../status/basic-contract-columns.tsx | 363 +++-- lib/basic-contract/status/basic-contract-table.tsx | 44 +- .../status/basicContract-table-toolbar-actions.tsx | 4 +- .../add-basic-contract-template-dialog.tsx | 1 + lib/basic-contract/validations.ts | 42 +- .../vendor-table/basic-contract-sign-dialog.tsx | 631 +++++--- .../vendor-table/basic-contract-table.tsx | 2 + .../vendor-table/survey-conditional.ts | 180 +-- lib/basic-contract/viewer/GtcClausesComponent.tsx | 837 +++++++++++ lib/basic-contract/viewer/SurveyComponent.tsx | 922 ++++++++++++ .../viewer/basic-contract-sign-viewer.tsx | 1515 ++++++-------------- 16 files changed, 5434 insertions(+), 1622 deletions(-) create mode 100644 lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx create mode 100644 lib/basic-contract/status-detail/basic-contracts-detail-table.tsx create mode 100644 lib/basic-contract/viewer/GtcClausesComponent.tsx create mode 100644 lib/basic-contract/viewer/SurveyComponent.tsx (limited to 'lib/basic-contract') diff --git a/lib/basic-contract/repository.ts b/lib/basic-contract/repository.ts index aab70106..237402d0 100644 --- a/lib/basic-contract/repository.ts +++ b/lib/basic-contract/repository.ts @@ -1,7 +1,7 @@ "use server"; import { asc, count,inArray ,eq} from "drizzle-orm"; -import { basicContractTemplates, basicContractView, type BasicContractTemplate } from "@/db/schema"; +import { basicContractTemplates, basicContractView,basicContractTemplateStatsView, type BasicContractTemplate } from "@/db/schema"; import { PgTransaction } from "drizzle-orm/pg-core"; import db from "@/db/db"; @@ -37,6 +37,47 @@ export async function selectBasicContracts( ) { const { where, orderBy, offset, limit } = options; + return tx + .select() + .from(basicContractTemplateStatsView) + .where(where || undefined) + .orderBy(...(orderBy || [asc(basicContractTemplateStatsView.lastActivityDate)])) + .offset(offset || 0) + .limit(limit || 50); +} + +export async function selectBasicContractsVendor( + tx: PgTransaction, + options: { + where?: any; + orderBy?: any[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset, limit } = options; + + return tx + .select() + .from(basicContractView) + .where(where || undefined) + .orderBy(...(orderBy || [asc(basicContractView.createdAt)])) + .offset(offset || 0) + .limit(limit || 50); +} + + +export async function selectBasicContractsById( + tx: PgTransaction, + options: { + where?: any; + orderBy?: any[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset, limit } = options; + return tx .select() .from(basicContractView) @@ -62,6 +103,18 @@ export async function countBasicContractTemplates( export async function countBasicContracts( tx: PgTransaction, where?: any +) { + const result = await tx + .select({ count: count() }) + .from(basicContractTemplateStatsView) + .where(where || undefined); + + return result[0]?.count || 0; +} + +export async function countBasicContractsVendor( + tx: PgTransaction, + where?: any ) { const result = await tx .select({ count: count() }) @@ -71,6 +124,17 @@ export async function countBasicContracts( return result[0]?.count || 0; } +export async function countBasicContractsById( + tx: PgTransaction, + where?: any +) { + const result = await tx + .select({ count: count() }) + .from(basicContractView) + .where(where || undefined); + + return result[0]?.count || 0; +} // 템플릿 생성 export async function insertBasicContractTemplate( diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 58463f16..194d27eb 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -17,19 +17,30 @@ import { complianceResponseFiles, complianceResponses, complianceSurveyTemplates, - vendorAttachments, - vendors, + vendorAttachments, basicContractTemplateStatsView, type BasicContractTemplate as DBBasicContractTemplate, type NewComplianceResponse, type NewComplianceResponseAnswer, - type NewComplianceResponseFile + type NewComplianceResponseFile, + gtcVendorDocuments, + gtcVendorClauses, + gtcClauses, + gtcDocuments, + vendors, + gtcNegotiationHistory, + type GtcVendorClause, + type GtcClause, + projects, + legalWorks } from "@/db/schema"; +import path from "path"; import { GetBasicContractTemplatesSchema, CreateBasicContractTemplateSchema, GetBasciContractsSchema, } from "./validations"; +import { readFile } from "fs/promises" import { insertBasicContractTemplate, @@ -39,7 +50,11 @@ import { getBasicContractTemplateById, selectBasicContracts, countBasicContracts, - findAllTemplates + findAllTemplates, + countBasicContractsById, + selectBasicContractsById, + selectBasicContractsVendor, + countBasicContractsVendor } from "./repository"; import { revalidatePath } from 'next/cache'; import { sendEmail } from "../mail/sendEmail"; @@ -47,7 +62,8 @@ import { headers } from 'next/headers'; import { filterColumns } from "@/lib/filter-columns"; import { differenceInDays, addYears, isBefore } from "date-fns"; import { deleteFile, saveFile } from "@/lib/file-stroage"; - +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" // 템플릿 추가 @@ -607,7 +623,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ - table: basicContractView, + table: basicContractTemplateStatsView, filters: input.filters, joinOperator: input.joinOperator, }); @@ -616,11 +632,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { let globalWhere if (input.search) { const s = `%${input.search}%` - globalWhere = or(ilike(basicContractView.templateName, s), - ilike(basicContractView.vendorName, s) - , ilike(basicContractView.vendorCode, s) - , ilike(basicContractView.vendorEmail, s) - , ilike(basicContractView.status, s) + globalWhere = or(ilike(basicContractTemplateStatsView.templateName, s), ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } @@ -638,9 +650,9 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { const orderBy = input.sort.length > 0 ? input.sort.map((item) => - item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id]) + item.desc ? desc(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id]) ) - : [asc(basicContractView.createdAt)]; + : [asc(basicContractTemplateStatsView.lastActivityDate)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { @@ -659,6 +671,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { return { data, pageCount }; } catch (err) { + console.log(err) // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } @@ -666,14 +679,14 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, - tags: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화 + tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화 } )(); } export async function getBasicContractsByVendorId( - input: GetBasciContractsSchema, + input: GetBasciContractsVendorSchema, vendorId: number ) { // return unstable_cache( @@ -726,14 +739,14 @@ export async function getBasicContractsByVendorId( // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { - const data = await selectBasicContracts(tx, { + const data = await selectBasicContractsVendor(tx, { where, orderBy, offset, limit: input.perPage, }); - const total = await countBasicContracts(tx, where); + const total = await countBasicContractsVendor(tx, where); return { data, total }; }); @@ -753,6 +766,91 @@ export async function getBasicContractsByVendorId( // )(); } + +export async function getBasicContractsByTemplateId( + input: GetBasciContractsByIdSchema, + templateId: number +) { + // return unstable_cache( + // async () => { + try { + + console.log(input.sort) + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: basicContractView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(basicContractView.templateName, s), + ilike(basicContractView.vendorName, s), + ilike(basicContractView.vendorCode, s), + ilike(basicContractView.vendorEmail, s), + ilike(basicContractView.status, s) + ); + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + // 벤더 ID 필터링 조건 추가 + const templateCondition = eq(basicContractView.templateId, templateId); + + const finalWhere = and( + // 항상 벤더 ID 조건을 포함 + templateCondition, + // 기존 조건들 + advancedWhere, + globalWhere + ); + + const where = finalWhere; + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id]) + ) + : [asc(basicContractView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectBasicContractsById(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countBasicContractsById(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트\ + console.log(err) + return { data: [], pageCount: 0 }; + } + // }, + // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가 + // { + // revalidate: 3600, + // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화 + // } + // )(); +} + export async function getAllTemplates(): Promise { try { return await findAllTemplates(); @@ -1677,4 +1775,1191 @@ export async function uploadSurveyFile(file: File, contractId: number, answerId: message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.' }; } +} + + +// 기존 응답 조회를 위한 타입 +export interface ExistingResponse { + responseId: number; + status: string; + completedAt: string | null; + answers: { + questionId: number; + answerValue: string | null; + detailText: string | null; + otherText: string | null; + files: Array<{ + id: number; + fileName: string; + filePath: string; + fileSize: number; + }>; + }[]; +} + +// 기존 응답 조회 서버 액션 +export async function getExistingSurveyResponse( + contractId: number, + templateId: number +): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> { + try { + // 1. 해당 계약서의 응답 조회 + const response = await db + .select() + .from(complianceResponses) + .where( + and( + eq(complianceResponses.basicContractId, contractId), + eq(complianceResponses.templateId, templateId) + ) + ) + .limit(1); + + if (!response || response.length === 0) { + return { success: true, data: null }; + } + + const responseData = response[0]; + + // 2. 해당 응답의 모든 답변 조회 + const answers = await db + .select({ + questionId: complianceResponseAnswers.questionId, + answerValue: complianceResponseAnswers.answerValue, + detailText: complianceResponseAnswers.detailText, + otherText: complianceResponseAnswers.otherText, + answerId: complianceResponseAnswers.id, + }) + .from(complianceResponseAnswers) + .where(eq(complianceResponseAnswers.responseId, responseData.id)); + + // 3. 각 답변의 파일들 조회 + const answerIds = answers.map(a => a.answerId); + const files = answerIds.length > 0 + ? await db + .select() + .from(complianceResponseFiles) + .where(inArray(complianceResponseFiles.answerId, answerIds)) + : []; + + // 4. 답변별 파일 그룹화 + const filesByAnswerId = files.reduce((acc, file) => { + if (!acc[file.answerId]) { + acc[file.answerId] = []; + } + acc[file.answerId].push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileSize: file.fileSize || 0, + }); + return acc; + }, {} as Record>); + + // 5. 최종 데이터 구성 + const answersWithFiles = answers.map(answer => ({ + questionId: answer.questionId, + answerValue: answer.answerValue, + detailText: answer.detailText, + otherText: answer.otherText, + files: filesByAnswerId[answer.answerId] || [], + })); + + return { + success: true, + data: { + responseId: responseData.id, + status: responseData.status, + completedAt: responseData.completedAt?.toISOString() || null, + answers: answersWithFiles, + }, + }; + + } catch (error) { + console.error('기존 설문 응답 조회 실패:', error); + return { + success: false, + data: null, + message: '기존 응답을 불러오는데 실패했습니다.' + }; + } +} + +export type GtcVendorData = { + vendorDocument: { + id: number; + name: string; + description: string | null; + version: string; + reviewStatus: string; + vendorId: number; + baseDocumentId: number; + vendorName: string; + vendorCode: string; + }; + clauses: Array<{ + id: number; + baseClauseId: number; + vendorDocumentId: number; + parentId: number | null; + depth: number; + sortOrder: string; + fullPath: string | null; + reviewStatus: string; + negotiationNote: string | null; + isExcluded: boolean; + + // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값) + effectiveItemNumber: string; + effectiveCategory: string | null; + effectiveSubtitle: string; + effectiveContent: string | null; + + // 기본 조항 정보 + baseItemNumber: string; + baseCategory: string | null; + baseSubtitle: string; + baseContent: string | null; + + // 수정 여부 + hasModifications: boolean; + isNumberModified: boolean; + isCategoryModified: boolean; + isSubtitleModified: boolean; + isContentModified: boolean; + + // 코멘트 관련 + hasComment: boolean; + pendingComment: string | null; + }>; +}; + +/** + * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다. + * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용) + * @returns GTC 벤더 데이터 또는 null + */ +export async function getVendorGtcData(contractId?: number): Promise { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.companyId) { + throw new Error("회사 정보가 없습니다."); + } + + console.log(contractId, "contractId"); + + const companyId = session.user.companyId; + const vendorId = companyId; // companyId를 vendorId로 사용 + + // 1. 계약 정보 가져오기 + const existingContract = await db.query.basicContract.findFirst({ + where: eq(basicContract.id, contractId), + }); + + if (!existingContract) { + throw new Error("계약을 찾을 수 없습니다."); + } + + // 2. 계약 템플릿 정보 가져오기 + const existingContractTemplate = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음 + }); + + if (!existingContractTemplate) { + throw new Error("계약 템플릿을 찾을 수 없습니다."); + } + + // 3. General 타입인지 확인 + const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general'); + + let targetBaseDocumentId: number; + + if (isGeneral) { + // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용 + const standardGtcDoc = await db.query.gtcDocuments.findFirst({ + where: and( + eq(gtcDocuments.type, 'standard'), + eq(gtcDocuments.isActive, true) + ), + orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선 + }); + + if (!standardGtcDoc) { + throw new Error("표준 GTC 문서를 찾을 수 없습니다."); + } + + targetBaseDocumentId = standardGtcDoc.id; + console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`); + + } else { + // General이 아닌 경우: 프로젝트별 GTC 문서 사용 + const projectCode = existingContractTemplate.templateName.split(" ")[0]; + + if (!projectCode) { + throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다."); + } + + // 프로젝트 찾기 + const existingProject = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + }); + + if (!existingProject) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`); + } + + // 해당 프로젝트의 GTC 문서 찾기 + const projectGtcDoc = await db.query.gtcDocuments.findFirst({ + where: and( + eq(gtcDocuments.type, 'project'), + eq(gtcDocuments.projectId, existingProject.id), + eq(gtcDocuments.isActive, true) + ), + orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선 + }); + + if (!projectGtcDoc) { + console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`); + + // 프로젝트별 GTC가 없으면 표준 GTC 사용 + const standardGtcDoc = await db.query.gtcDocuments.findFirst({ + where: and( + eq(gtcDocuments.type, 'standard'), + eq(gtcDocuments.isActive, true) + ), + orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] + }); + + if (!standardGtcDoc) { + throw new Error("표준 GTC 문서도 찾을 수 없습니다."); + } + + targetBaseDocumentId = standardGtcDoc.id; + } else { + targetBaseDocumentId = projectGtcDoc.id; + console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`); + } + } + + // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌) + const vendorDocResult = await db + .select({ + id: gtcVendorDocuments.id, + name: gtcVendorDocuments.name, + description: gtcVendorDocuments.description, + version: gtcVendorDocuments.version, + reviewStatus: gtcVendorDocuments.reviewStatus, + vendorId: gtcVendorDocuments.vendorId, + baseDocumentId: gtcVendorDocuments.baseDocumentId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + }) + .from(gtcVendorDocuments) + .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id)) + .where( + and( + eq(gtcVendorDocuments.vendorId, vendorId), + eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1); + + // 벤더 문서가 없으면 기본 정보로 생성 + const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : { + id: null, // 벤더 문서가 아직 생성되지 않음 + name: `GTC 검토 (벤더 ID: ${vendorId})`, + description: "기본 GTC 조항 검토", + version: "1.0", + reviewStatus: "pending", + vendorId: vendorId, + baseDocumentId: targetBaseDocumentId, + vendorName: "Unknown Vendor", + vendorCode: "UNKNOWN" + }; + + if (vendorDocResult.length === 0) { + console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`); + } + + // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께) + const clausesResult = await db + .select({ + // 기본 조항 정보 (메인) + baseClauseId: gtcClauses.id, + baseItemNumber: gtcClauses.itemNumber, + baseCategory: gtcClauses.category, + baseSubtitle: gtcClauses.subtitle, + baseContent: gtcClauses.content, + baseParentId: gtcClauses.parentId, + baseDepth: gtcClauses.depth, + baseSortOrder: gtcClauses.sortOrder, + baseFullPath: gtcClauses.fullPath, + + // 벤더 조항 정보 (있는 경우만) + vendorClauseId: gtcVendorClauses.id, + vendorDocumentId: gtcVendorClauses.vendorDocumentId, + reviewStatus: gtcVendorClauses.reviewStatus, + negotiationNote: gtcVendorClauses.negotiationNote, + isExcluded: gtcVendorClauses.isExcluded, + + // 수정된 값들 (있는 경우만) + modifiedItemNumber: gtcVendorClauses.modifiedItemNumber, + modifiedCategory: gtcVendorClauses.modifiedCategory, + modifiedSubtitle: gtcVendorClauses.modifiedSubtitle, + modifiedContent: gtcVendorClauses.modifiedContent, + + // 수정 여부 + isNumberModified: gtcVendorClauses.isNumberModified, + isCategoryModified: gtcVendorClauses.isCategoryModified, + isSubtitleModified: gtcVendorClauses.isSubtitleModified, + isContentModified: gtcVendorClauses.isContentModified, + }) + .from(gtcClauses) + .leftJoin(gtcVendorClauses, and( + eq(gtcVendorClauses.baseClauseId, gtcClauses.id), + vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`, // 벤더 문서가 없으면 조인하지 않음 + eq(gtcVendorClauses.isActive, true) + )) + .where( + and( + eq(gtcClauses.documentId, targetBaseDocumentId), + eq(gtcClauses.isActive, true) + ) + ) + .orderBy(gtcClauses.sortOrder); + + // 6. 데이터 변환 및 추가 정보 계산 + const clauses = clausesResult.map(clause => { + // 벤더별 수정사항이 있는지 확인 + const hasVendorData = !!clause.vendorClauseId; + + const hasModifications = hasVendorData && ( + clause.isNumberModified || + clause.isCategoryModified || + clause.isSubtitleModified || + clause.isContentModified + ); + + const hasComment = hasVendorData && !!clause.negotiationNote; + + return { + // 벤더 조항 ID (있는 경우만, 없으면 null) + // id: clause.vendorClauseId, + id: clause.baseClauseId, + vendorClauseId: clause.vendorClauseId, + vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null, + + // 기본 조항의 계층 구조 정보 사용 + parentId: clause.baseParentId, + depth: clause.baseDepth, + sortOrder: clause.baseSortOrder, + fullPath: clause.baseFullPath, + + // 상태 정보 (벤더 데이터가 있는 경우만) + reviewStatus: clause.reviewStatus || 'pending', + negotiationNote: clause.negotiationNote, + isExcluded: clause.isExcluded || false, + + // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값) + effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber, + effectiveCategory: clause.modifiedCategory || clause.baseCategory, + effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle, + effectiveContent: clause.modifiedContent || clause.baseContent, + + // 기본 조항 정보 + baseItemNumber: clause.baseItemNumber, + baseCategory: clause.baseCategory, + baseSubtitle: clause.baseSubtitle, + baseContent: clause.baseContent, + + // 수정 여부 + hasModifications, + isNumberModified: clause.isNumberModified || false, + isCategoryModified: clause.isCategoryModified || false, + isSubtitleModified: clause.isSubtitleModified || false, + isContentModified: clause.isContentModified || false, + + // 코멘트 관련 + hasComment, + pendingComment: null, // 클라이언트에서 관리 + }; + }); + + return { + vendorDocument, + clauses, + }; + + } catch (error) { + console.error('GTC 벤더 데이터 가져오기 실패:', error); + throw error; + } +} + + +interface ClauseUpdateData { + itemNumber: string; + category: string | null; + subtitle: string; + content: string | null; + comment: string; +} + +interface VendorDocument { + id: number | null; + vendorId: number; + baseDocumentId: number; + name: string; + description: string; + version: string; +} + +export async function updateVendorClause( + baseClauseId: number, + vendorClauseId: number | null, + clauseData: ClauseUpdateData, + vendorDocument?: VendorDocument +): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.companyId) { + return { success: false, error: "회사 정보가 없습니다." }; + } + + const companyId = session.user.companyId; + const vendorId = companyId; // companyId를 vendorId로 사용 + const userId = Number(session.user.id); + + // 1. 기본 조항 정보 가져오기 (비교용) + const baseClause = await db.query.gtcClauses.findFirst({ + where: eq(gtcClauses.id, baseClauseId), + }); + + if (!baseClause) { + return { success: false, error: "기본 조항을 찾을 수 없습니다." }; + } + + // 2. 벤더 문서 ID 확보 (없으면 생성) + let finalVendorDocumentId = vendorDocument?.id; + + if (!finalVendorDocumentId && vendorDocument) { + // 벤더 문서 생성 + const newVendorDoc = await db.insert(gtcVendorDocuments).values({ + vendorId: vendorId, + baseDocumentId: vendorDocument.baseDocumentId, + name: vendorDocument.name, + description: vendorDocument.description, + version: vendorDocument.version, + reviewStatus: 'reviewing', + createdById: userId, + updatedById: userId, + }).returning({ id: gtcVendorDocuments.id }); + + if (newVendorDoc.length === 0) { + return { success: false, error: "벤더 문서 생성에 실패했습니다." }; + } + + finalVendorDocumentId = newVendorDoc[0].id; + console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`); + } + + if (!finalVendorDocumentId) { + return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." }; + } + + // 3. 수정 여부 확인 + const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber; + const isCategoryModified = clauseData.category !== baseClause.category; + const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle; + const isContentModified = clauseData.content !== baseClause.content; + + const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified; + const hasComment = !!(clauseData.comment?.trim()); + + // 4. 벤더 조항 데이터 준비 + const vendorClauseData = { + vendorDocumentId: finalVendorDocumentId, + baseClauseId: baseClauseId, + parentId: baseClause.parentId, + depth: baseClause.depth, + sortOrder: baseClause.sortOrder, + fullPath: baseClause.fullPath, + + // 수정된 값들 (수정되지 않았으면 null로 저장) + modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null, + modifiedCategory: isCategoryModified ? clauseData.category : null, + modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null, + modifiedContent: isContentModified ? clauseData.content : null, + + // 수정 여부 플래그 + isNumberModified, + isCategoryModified, + isSubtitleModified, + isContentModified, + + // 상태 정보 + reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft', + negotiationNote: clauseData.comment?.trim() || null, + editReason: clauseData.comment?.trim() || null, // 수정 이유도 동일하게 저장 + + updatedAt: new Date(), + updatedById: userId, + }; + + let finalVendorClauseId = vendorClauseId; + + // 5. 벤더 조항 생성 또는 업데이트 + if (vendorClauseId) { + // 기존 벤더 조항 업데이트 + await db + .update(gtcVendorClauses) + .set(vendorClauseData) + .where(eq(gtcVendorClauses.id, vendorClauseId)); + + console.log(`벤더 조항 업데이트: ${vendorClauseId}`); + } else { + // 새 벤더 조항 생성 + const newVendorClause = await db.insert(gtcVendorClauses).values({ + ...vendorClauseData, + createdById: userId, + }).returning({ id: gtcVendorClauses.id }); + + if (newVendorClause.length === 0) { + return { success: false, error: "벤더 조항 생성에 실패했습니다." }; + } + + finalVendorClauseId = newVendorClause[0].id; + console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`); + } + + // 6. 협의 이력에 기록 + if (hasAnyModifications || hasComment) { + const historyAction = hasAnyModifications ? 'modified' : 'commented'; + const historyComment = hasAnyModifications + ? `조항 수정: ${clauseData.comment || '수정 이유 없음'}` + : clauseData.comment; + + await db.insert(gtcNegotiationHistory).values({ + vendorClauseId: finalVendorClauseId, + action: historyAction, + comment: historyComment?.trim(), + actorType: 'vendor', + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + }); + } + + return { + success: true, + vendorClauseId: finalVendorClauseId, + vendorDocumentId: finalVendorDocumentId + }; + + } catch (error) { + console.error('벤더 조항 업데이트 실패:', error); + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }; + } +} + +// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경 +export async function updateVendorClauseComment( + clauseId: number, + comment: string +): Promise<{ success: boolean; error?: string }> { + console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.'); + + // 기본 조항 정보 가져오기 + const baseClause = await db.query.gtcClauses.findFirst({ + where: eq(gtcClauses.id, clauseId), + }); + + if (!baseClause) { + return { success: false, error: "기본 조항을 찾을 수 없습니다." }; + } + + // 기존 벤더 조항 찾기 + const session = await getServerSession(authOptions); + const vendorId = session?.user?.companyId; + + const existingVendorClause = await db.query.gtcVendorClauses.findFirst({ + where: and( + eq(gtcVendorClauses.baseClauseId, clauseId), + eq(gtcVendorClauses.isActive, true) + ), + with: { + vendorDocument: true + } + }); + + const clauseData: ClauseUpdateData = { + itemNumber: baseClause.itemNumber, + category: baseClause.category, + subtitle: baseClause.subtitle, + content: baseClause.content, + comment: comment, + }; + + const result = await updateVendorClause( + clauseId, + existingVendorClause?.id || null, + clauseData, + existingVendorClause?.vendorDocument || undefined + ); + + return { + success: result.success, + error: result.error + }; +} + + +/** + * 벤더 조항 코멘트들의 상태 체크 + */ +export async function checkVendorClausesCommentStatus( + vendorDocumentId: number +): Promise<{ hasComments: boolean; commentCount: number }> { + try { + const clausesWithComments = await db + .select({ + id: gtcVendorClauses.id, + negotiationNote: gtcVendorClauses.negotiationNote + }) + .from(gtcVendorClauses) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId), + eq(gtcVendorClauses.isActive, true) + ) + ); + + const commentCount = clausesWithComments.filter( + clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0 + ).length; + + return { + hasComments: commentCount > 0, + commentCount, + }; + + } catch (error) { + console.error('벤더 조항 코멘트 상태 체크 실패:', error); + return { hasComments: false, commentCount: 0 }; + } +} + +/** + * 특정 템플릿의 기본 정보를 조회하는 서버 액션 + * @param templateId - 조회할 템플릿의 ID + * @returns 템플릿 기본 정보 또는 null + */ +export async function getBasicContractTemplateInfo(templateId: number) { + try { + const templateInfo = await db + .select({ + templateId: basicContractTemplates.id, + templateName: basicContractTemplates.templateName, + revision: basicContractTemplates.revision, + status: basicContractTemplates.status, + legalReviewRequired: basicContractTemplates.legalReviewRequired, + validityPeriod: basicContractTemplates.validityPeriod, + fileName: basicContractTemplates.fileName, + filePath: basicContractTemplates.filePath, + createdAt: basicContractTemplates.createdAt, + updatedAt: basicContractTemplates.updatedAt, + createdBy: basicContractTemplates.createdBy, + updatedBy: basicContractTemplates.updatedBy, + disposedAt: basicContractTemplates.disposedAt, + restoredAt: basicContractTemplates.restoredAt, + }) + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, templateId)) + .then((res) => res[0] || null) + + return templateInfo + } catch (error) { + console.error("Error fetching template info:", error) + return null + } +} + + + +/** + * 카테고리 자동 분류 함수 + */ +function getCategoryFromTemplateName(templateName: string | null): string { + if (!templateName) return "기타" + + const templateNameLower = templateName.toLowerCase() + + if (templateNameLower.includes("준법")) { + return "CP" + } else if (templateNameLower.includes("gtc")) { + return "GTC" + } + + return "기타" +} + +/** + * 법무검토 요청 서버 액션 + */ +export async function requestLegalReviewAction( + contractIds: number[], + reviewNote?: string +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select({ + id: basicContractView.id, + vendorId: basicContractView.vendorId, + vendorCode: basicContractView.vendorCode, + vendorName: basicContractView.vendorName, + templateName: basicContractView.templateName, + legalReviewRequired: basicContractView.legalReviewRequired, + legalReviewRequestedAt: basicContractView.legalReviewRequestedAt, + }) + .from(basicContractView) + .where(inArray(basicContractView.id, contractIds)) + + if (contracts.length === 0) { + return { + success: false, + message: "선택된 계약서를 찾을 수 없습니다." + } + } + + // 법무검토 요청 가능한 계약서 필터링 + const eligibleContracts = contracts.filter(contract => + contract.legalReviewRequired && !contract.legalReviewRequestedAt + ) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "법무검토 요청 가능한 계약서가 없습니다." + } + } + + const currentDate = new Date() + const reviewer = session.user.name || session.user.email || "알 수 없음" + + // 트랜잭션으로 처리 + const results = await db.transaction(async (tx) => { + const legalWorkResults = [] + const contractUpdateResults = [] + + // 각 계약서에 대해 legalWorks 레코드 생성 + for (const contract of eligibleContracts) { + const category = getCategoryFromTemplateName(contract.templateName) + + // legalWorks에 레코드 삽입 + const legalWorkResult = await tx.insert(legalWorks).values({ + basicContractId: contract.id, // 레퍼런스 ID + category: category, + status: "검토요청", + vendorId: contract.vendorId, + vendorCode: contract.vendorCode, + vendorName: contract.vendorName || "업체명 없음", + isUrgent: false, + consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식 + reviewer: reviewer, + hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정 + createdAt: currentDate, + updatedAt: currentDate, + }).returning({ id: legalWorks.id }) + + legalWorkResults.push(legalWorkResult[0]) + + // basicContract 테이블의 legalReviewRequestedAt 업데이트 + const contractUpdateResult = await tx + .update(basicContract) + .set({ + legalReviewRequestedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(basicContract.id, contract.id)) + .returning({ id: basicContract.id }) + + contractUpdateResults.push(contractUpdateResult[0]) + } + + return { + legalWorks: legalWorkResults, + contractUpdates: contractUpdateResults + } + }) + + + console.log("법무검토 요청 완료:", { + requestedBy: reviewer, + contractIds: eligibleContracts.map(c => c.id), + legalWorkIds: results.legalWorks.map(r => r.id), + reviewNote, + }) + + return { + success: true, + message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`, + data: { + processedCount: eligibleContracts.length, + totalRequested: contractIds.length, + legalWorkIds: results.legalWorks.map(r => r.id), + } + } + + } catch (error) { + console.error("법무검토 요청 중 오류:", error) + + return { + success: false, + message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.", + } + } +} + +export async function processBuyerSignatureAction( + contractId: number, + signedFileData: ArrayBuffer, + fileName: string +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 및 상태 확인 + const contract = await db + .select() + .from(basicContractView) + .where(eq(basicContractView.id, contractId)) + .limit(1) + + if (contract.length === 0) { + return { + success: false, + message: "계약서를 찾을 수 없습니다." + } + } + + const contractData = contract[0] + + // 최종승인 가능 상태 확인 + if (contractData.completedAt !== null) { + return { + success: false, + message: "이미 완료된 계약서입니다." + } + } + + if (!contractData.signedFilePath) { + return { + success: false, + message: "협력업체 서명이 완료되지 않았습니다." + } + } + + if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) { + return { + success: false, + message: "법무검토가 완료되지 않았습니다." + } + } + + // 파일 저장 로직 (기존 파일 덮어쓰기) + // TODO: 실제 파일 저장 구현 + const saveResult = await saveFile({signedFileData,directory: "basicContract/signed" }); + + const currentDate = new Date() + + // 계약서 상태 업데이트 + const updatedContract = await db + .update(basicContract) + .set({ + buyerSignedAt: currentDate, + completedAt: currentDate, + status: "COMPLETED", + updatedAt: currentDate, + // signedFilePath: savedFilePath, // 새로운 파일 경로로 업데이트 + }) + .where(eq(basicContract.id, contractId)) + .returning() + + // 캐시 재검증 + revalidatePath("/contracts") + + console.log("구매자 서명 및 최종승인 완료:", { + contractId, + buyerSigner: session.user.name || session.user.email, + completedAt: currentDate, + }) + + return { + success: true, + message: "계약서 최종승인이 완료되었습니다.", + data: { + contractId, + completedAt: currentDate, + } + } + + } catch (error) { + console.error("구매자 서명 처리 중 오류:", error) + + return { + success: false, + message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.", + } + } +} + +/** + * 일괄 최종승인 (서명 다이얼로그 호출용) + */ +export async function prepareFinalApprovalAction( + contractIds: number[] +): Promise<{ success: boolean; message: string; contracts?: any[] }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select() + .from(basicContractView) + .where(inArray(basicContractView.id, contractIds)) + + if (contracts.length === 0) { + return { + success: false, + message: "선택된 계약서를 찾을 수 없습니다." + } + } + + // 최종승인 가능한 계약서 필터링 + const eligibleContracts = contracts.filter(contract => { + if (contract.completedAt !== null || !contract.signedFilePath) { + return false + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false + } + return true + }) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "최종승인 가능한 계약서가 없습니다." + } + } + + // 서명 다이얼로그에서 사용할 수 있는 형태로 변환 + const contractsForSigning = eligibleContracts.map(contract => ({ + id: contract.id, + templateName: contract.templateName, + signedFilePath: contract.signedFilePath, + signedFileName: contract.signedFileName, + vendorName: contract.vendorName, + vendorCode: contract.vendorCode, + requestedByName: "구매팀", // 최종승인자 표시 + createdAt: contract.createdAt, + // 다른 필요한 필드들... + })) + + return { + success: true, + message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`, + contracts: contractsForSigning + } + + } catch (error) { + console.error("최종승인 준비 중 오류:", error) + + return { + success: false, + message: "최종승인 준비 중 오류가 발생했습니다.", + } + } +} + +/** + * 서명 없이 승인만 처리 (간단한 승인 방식) + */ +export async function quickFinalApprovalAction( + contractIds: number[] +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select() + .from(basicContract) + .where(inArray(basicContract.id, contractIds)) + + // 승인 가능한 계약서 필터링 + const eligibleContracts = contracts.filter(contract => { + if (contract.completedAt !== null || !contract.signedFilePath) { + return false + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false + } + return true + }) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "최종승인 가능한 계약서가 없습니다." + } + } + + const currentDate = new Date() + const approver = session.user.name || session.user.email || "알 수 없음" + + // 일괄 업데이트 + const updatedContracts = await db + .update(basicContract) + .set({ + buyerSignedAt: currentDate, + completedAt: currentDate, + status: "COMPLETED", + updatedAt: currentDate, + }) + .where(inArray(basicContract.id, eligibleContracts.map(c => c.id))) + .returning({ id: basicContract.id }) + + // 캐시 재검증 + revalidatePath("/contracts") + + console.log("일괄 최종승인 완료:", { + approver, + contractIds: updatedContracts.map(c => c.id), + completedAt: currentDate, + }) + + return { + success: true, + message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`, + data: { + processedCount: updatedContracts.length, + contractIds: updatedContracts.map(c => c.id), + } + } + + } catch (error) { + console.error("일괄 최종승인 중 오류:", error) + + return { + success: false, + message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.", + } + } +} + + +export async function getVendorSignatureFile() { + try { + // 세션에서 사용자 정보 가져오기 + const session = await getServerSession(authOptions) + + if (!session?.user?.companyId) { + throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.") + } + + // 조건에 맞는 vendor attachment 찾기 + const signatureAttachment = await db.query.vendorAttachments.findFirst({ + where: and( + eq(vendorAttachments.vendorId, session.user.companyId), + eq(vendorAttachments.attachmentType, "SIGNATURE") + ) + }) + + if (!signatureAttachment) { + return { + success: false, + error: "서명 파일을 찾을 수 없습니다." + } + } + + // 파일 읽기 + let filePath: string; + const nasPath = process.env.NAS_PATH || "/evcp_nas" + + + if (process.env.NODE_ENV === 'production') { + // ✅ 프로덕션: NAS 경로 사용 + filePath = path.join(nasPath, signatureAttachment.filePath); + + } else { + // 개발: public 폴더 + filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath); + } + + const fileBuffer = await readFile(filePath) + + // Base64로 인코딩 + const base64File = fileBuffer.toString('base64') + + return { + success: true, + data: { + id: signatureAttachment.id, + fileName: signatureAttachment.fileName, + fileType: signatureAttachment.fileType, + base64: base64File, + // 웹에서 사용할 수 있는 data URL 형식도 제공 + dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}` + } + } + + } catch (error) { + console.error("서명 파일 조회 중 오류:", error) + console.log("서명 파일 조회 중 오류:", error) + + return { + success: false, + error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다." + } + } } \ No newline at end of file diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx new file mode 100644 index 00000000..3b5cdd21 --- /dev/null +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -0,0 +1,622 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileDown, Mail, Scale, CheckCircle, AlertTriangle, Send, Gavel, Check, FileSignature } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { downloadFile } from "@/lib/file-download" +import { Button } from "@/components/ui/button" +import { BasicContractView } from "@/db/schema" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction } from "../service" +import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" + +interface BasicContractDetailTableToolbarActionsProps { + table: Table +} + +export function BasicContractDetailTableToolbarActions({ table }: BasicContractDetailTableToolbarActionsProps) { + // 선택된 행들 가져오기 + const selectedRows = table.getSelectedRowModel().rows + const hasSelectedRows = selectedRows.length > 0 + + // 다이얼로그 상태 + const [resendDialog, setResendDialog] = React.useState(false) + const [legalReviewDialog, setLegalReviewDialog] = React.useState(false) + const [finalApproveDialog, setFinalApproveDialog] = React.useState(false) + const [loading, setLoading] = React.useState(false) + const [reviewNote, setReviewNote] = React.useState("") + const [buyerSignDialog, setBuyerSignDialog] = React.useState(false) + const [contractsToSign, setContractsToSign] = React.useState([]) + + // 각 버튼별 활성화 조건 계산 + const canBulkDownload = hasSelectedRows && selectedRows.some(row => + row.original.signedFilePath && row.original.signedFileName && row.original.vendorSignedAt + ) + + const canBulkResend = hasSelectedRows + + const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => + row.original.legalReviewRequired && !row.original.legalReviewRequestedAt + ) + + const canFinalApprove = hasSelectedRows && selectedRows.some(row => { + const contract = row.original; + if (contract.completedAt !== null || !contract.signedFilePath) { + return false; + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false; + } + return true; + }); + + // 필터링된 계약서들 계산 + const resendContracts = selectedRows.map(row => row.original) + + const legalReviewContracts = selectedRows + .map(row => row.original) + .filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt) + + const finalApproveContracts = selectedRows + .map(row => row.original) + .filter(contract => { + if (contract.completedAt !== null || !contract.signedFilePath) { + return false; + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false; + } + return true; + }); + + const contractsWithoutLegalReview = finalApproveContracts.filter(contract => + !contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt + ); + + // 대량 재발송 + const handleBulkResend = async () => { + if (!hasSelectedRows) { + toast.error("재발송할 계약서를 선택해주세요") + return + } + setResendDialog(true) + } + + // 선택된 계약서들 일괄 다운로드 + const handleBulkDownload = async () => { + if (!canBulkDownload) { + toast.error("다운로드할 파일이 있는 계약서를 선택해주세요") + return + } + + const selectedContracts = selectedRows + .map(row => row.original) + .filter(contract => contract.signedFilePath && contract.signedFileName) + + if (selectedContracts.length === 0) { + toast.error("다운로드할 파일이 없습니다") + return + } + + // 다운로드 시작 알림 + toast.success(`${selectedContracts.length}건의 파일 다운로드를 시작합니다`) + + let successCount = 0 + let failedCount = 0 + const failedFiles: string[] = [] + + // 순차적으로 다운로드 (병렬 다운로드는 브라우저 제한으로 인해 문제가 될 수 있음) + for (let i = 0; i < selectedContracts.length; i++) { + const contract = selectedContracts[i] + + try { + // 진행 상황 표시 + if (selectedContracts.length > 3) { + toast.loading(`다운로드 중... (${i + 1}/${selectedContracts.length})`, { + id: 'bulk-download-progress' + }) + } + + const result = await downloadFile( + contract.signedFilePath!, + contract.signedFileName!, + { + action: 'download', + showToast: false, // 개별 토스트는 비활성화 + onError: (error) => { + console.error(`다운로드 실패 - ${contract.signedFileName}:`, error) + failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`) + failedCount++ + }, + onSuccess: (fileName) => { + console.log(`다운로드 성공 - ${fileName}`) + successCount++ + } + } + ) + + if (result.success) { + successCount++ + } else { + failedCount++ + failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`) + } + + // 다운로드 간격 (브라우저 부하 방지) + if (i < selectedContracts.length - 1) { + await new Promise(resolve => setTimeout(resolve, 300)) + } + + } catch (error) { + console.error(`다운로드 에러 - ${contract.signedFileName}:`, error) + failedCount++ + failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`) + } + } + + // 진행 상황 토스트 제거 + toast.dismiss('bulk-download-progress') + + // 최종 결과 표시 + if (successCount === selectedContracts.length) { + toast.success(`모든 파일 다운로드 완료 (${successCount}건)`) + } else if (successCount > 0) { + toast.warning( + `일부 파일 다운로드 완료\n성공: ${successCount}건, 실패: ${failedCount}건`, + { + duration: 5000, + description: failedFiles.length > 0 + ? `실패한 파일: ${failedFiles.slice(0, 3).join(', ')}${failedFiles.length > 3 ? ` 외 ${failedFiles.length - 3}건` : ''}` + : undefined + } + ) + } else { + toast.error( + `모든 파일 다운로드 실패 (${failedCount}건)`, + { + duration: 5000, + description: failedFiles.length > 0 + ? `실패한 파일: ${failedFiles.slice(0, 3).join(', ')}${failedFiles.length > 3 ? ` 외 ${failedFiles.length - 3}건` : ''}` + : undefined + } + ) + } + + console.log("일괄 다운로드 완료:", { + total: selectedContracts.length, + success: successCount, + failed: failedCount, + failedFiles + }) + } + + // 법무검토 요청 + const handleLegalReviewRequest = async () => { + if (!canRequestLegalReview) { + toast.error("법무검토 요청 가능한 계약서를 선택해주세요") + return + } + setLegalReviewDialog(true) + } + + // 최종승인 + const handleFinalApprove = async () => { + if (!canFinalApprove) { + toast.error("최종승인 가능한 계약서를 선택해주세요") + return + } + setFinalApproveDialog(true) + } + + // 재발송 확인 + const confirmResend = async () => { + setLoading(true) + try { + // TODO: 서버액션 호출 + // await resendContractsAction(resendContracts.map(c => c.id)) + + console.log("대량 재발송:", resendContracts) + toast.success(`${resendContracts.length}건의 계약서 재발송을 완료했습니다`) + setResendDialog(false) + table.toggleAllPageRowsSelected(false) // 선택 해제 + } catch (error) { + toast.error("재발송 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + // 법무검토 요청 확인 + const confirmLegalReview = async () => { + setLoading(true) + try { + // TODO: 서버액션 호출 + await requestLegalReviewAction(legalReviewContracts.map(c => c.id), reviewNote) + + console.log("법무검토 요청:", legalReviewContracts, "메모:", reviewNote) + toast.success(`${legalReviewContracts.length}건의 법무검토 요청을 완료했습니다`) + setLegalReviewDialog(false) + setReviewNote("") + table.toggleAllPageRowsSelected(false) // 선택 해제 + } catch (error) { + toast.error("법무검토 요청 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + // 최종승인 확인 (수정됨) + const confirmFinalApprove = async () => { + setLoading(true) + try { + // 먼저 서명 가능한 계약서들을 준비 + const prepareResult = await prepareFinalApprovalAction( + finalApproveContracts.map(c => c.id) + ) + + if (prepareResult.success && prepareResult.contracts) { + // 서명이 필요한 경우 서명 다이얼로그 열기 + setContractsToSign(prepareResult.contracts) + setFinalApproveDialog(false) // 기존 다이얼로그는 닫기 + // buyerSignDialog는 더 이상 필요 없으므로 제거 + } else { + toast.error(prepareResult.message) + } + } catch (error) { + toast.error("최종승인 준비 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + // 구매자 서명 완료 콜백 + const handleBuyerSignComplete = () => { + setContractsToSign([]) // 계약서 목록 초기화하여 BasicContractSignDialog 언마운트 + table.toggleAllPageRowsSelected(false) + toast.success("모든 계약서의 최종승인이 완료되었습니다!") + } + + // 빠른 승인 (서명 없이) + const confirmQuickApproval = async () => { + setLoading(true) + try { + const result = await quickFinalApprovalAction( + finalApproveContracts.map(c => c.id) + ) + + if (result.success) { + toast.success(result.message) + setFinalApproveDialog(false) + table.toggleAllPageRowsSelected(false) + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("최종승인 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + return ( + <> +
+ {/* 일괄 다운로드 버튼 */} + + + {/* 재발송 버튼 */} + + + {/* 법무검토 요청 버튼 */} + + + {/* 최종승인 버튼 */} + + + {/* 실제 구매자 서명을 위한 BasicContractSignDialog */} + {contractsToSign.length > 0 && ( + 0} + mode="buyer" // 구매자 모드 prop + t={(key) => key} + /> + )} + + {/* Export 버튼 */} + +
+ + {/* 재발송 다이얼로그 */} + + + + + + 계약서 재발송 확인 + + + 선택한 {resendContracts.length}건의 계약서를 재발송합니다. + + + +
+
+ {resendContracts.map((contract, index) => ( +
+
+
{contract.vendorName || '업체명 없음'}
+
+ {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} +
+
+ {contract.status} +
+ ))} +
+
+ + + + + +
+
+ + {/* 법무검토 요청 다이얼로그 */} + + + + + + 법무검토 요청 + + + 선택한 {legalReviewContracts.length}건의 계약서에 대한 법무검토를 요청합니다. + + + +
+
+
+ {legalReviewContracts.map((contract, index) => ( +
+
+
{contract.vendorName || '업체명 없음'}
+
+ {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} +
+
+ {contract.status} +
+ ))} +
+
+ + + +
+ +