From aa86729f9a2ab95346a2851e3837de1c367aae17 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 20 Jun 2025 11:37:31 +0000 Subject: (대표님) 20250620 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/evaluation-target-list/service.ts | 616 +++++++++++- .../table/evaluation-target-action-dialogs.tsx | 384 ++++++++ .../table/evaluation-target-table.tsx | 154 +-- .../table/evaluation-targets-columns.tsx | 459 ++++++--- .../table/evaluation-targets-filter-sheet.tsx | 255 ++++- .../table/evaluation-targets-toolbar-actions.tsx | 138 +-- .../manual-create-evaluation-target-dialog.tsx | 151 +-- .../table/update-evaluation-target.tsx | 760 +++++++++++++++ lib/evaluation-target-list/validation.ts | 12 +- lib/evaluation/service.ts | 0 lib/evaluation/table/evaluation-columns.tsx | 441 +++++++++ lib/evaluation/table/evaluation-filter-sheet.tsx | 1031 ++++++++++++++++++++ lib/evaluation/table/evaluation-table.tsx | 462 +++++++++ lib/evaluation/validation.ts | 46 + lib/forms/services.ts | 38 +- lib/incoterms/table/delete-incoterms-dialog.tsx | 154 +++ lib/incoterms/table/incoterms-add-dialog.tsx | 12 +- lib/incoterms/table/incoterms-edit-sheet.tsx | 60 +- lib/incoterms/table/incoterms-table-columns.tsx | 206 ++-- lib/incoterms/table/incoterms-table-toolbar.tsx | 41 +- lib/incoterms/table/incoterms-table.tsx | 102 +- lib/mail/templates/evaluation-review-request.hbs | 162 +++ .../table/delete-payment-terms-dialog.tsx | 154 +++ .../table/payment-terms-add-dialog.tsx | 10 +- .../table/payment-terms-edit-sheet.tsx | 59 +- .../table/payment-terms-table-columns.tsx | 206 ++-- .../table/payment-terms-table-toolbar.tsx | 41 +- lib/payment-terms/table/payment-terms-table.tsx | 102 +- lib/project-gtc/service.ts | 389 ++++++++ lib/project-gtc/table/add-project-dialog.tsx | 296 ++++++ lib/project-gtc/table/delete-gtc-file-dialog.tsx | 160 +++ .../table/project-gtc-table-columns.tsx | 364 +++++++ .../table/project-gtc-table-toolbar-actions.tsx | 74 ++ lib/project-gtc/table/project-gtc-table.tsx | 100 ++ lib/project-gtc/table/update-gtc-file-sheet.tsx | 222 +++++ lib/project-gtc/table/view-gtc-file-dialog.tsx | 230 +++++ lib/project-gtc/validations.ts | 32 + lib/techsales-rfq/service.ts | 245 ++++- .../table/vendor-quotations-table-columns.tsx | 88 +- .../table/vendor-quotations-table.tsx | 115 ++- 40 files changed, 7838 insertions(+), 733 deletions(-) create mode 100644 lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx create mode 100644 lib/evaluation-target-list/table/update-evaluation-target.tsx create mode 100644 lib/evaluation/service.ts create mode 100644 lib/evaluation/table/evaluation-columns.tsx create mode 100644 lib/evaluation/table/evaluation-filter-sheet.tsx create mode 100644 lib/evaluation/table/evaluation-table.tsx create mode 100644 lib/evaluation/validation.ts create mode 100644 lib/incoterms/table/delete-incoterms-dialog.tsx create mode 100644 lib/mail/templates/evaluation-review-request.hbs create mode 100644 lib/payment-terms/table/delete-payment-terms-dialog.tsx create mode 100644 lib/project-gtc/service.ts create mode 100644 lib/project-gtc/table/add-project-dialog.tsx create mode 100644 lib/project-gtc/table/delete-gtc-file-dialog.tsx create mode 100644 lib/project-gtc/table/project-gtc-table-columns.tsx create mode 100644 lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx create mode 100644 lib/project-gtc/table/project-gtc-table.tsx create mode 100644 lib/project-gtc/table/update-gtc-file-sheet.tsx create mode 100644 lib/project-gtc/table/view-gtc-file-dialog.tsx create mode 100644 lib/project-gtc/validations.ts (limited to 'lib') diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index 62f0f0ef..572b468d 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -1,13 +1,13 @@ 'use server' -import { and, or, desc, asc, ilike, eq, isNull, sql, count } from "drizzle-orm"; +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { filterColumns } from "@/lib/filter-columns"; import db from "@/db/db"; -import { - evaluationTargets, - evaluationTargetReviewers, +import { + evaluationTargets, + evaluationTargetReviewers, evaluationTargetReviews, users, vendors, @@ -21,7 +21,9 @@ import { } from "@/db/schema"; import { GetEvaluationTargetsSchema } from "./validation"; import { PgTransaction } from "drizzle-orm/pg-core"; - +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { sendEmail } from "../mail/sendEmail"; export async function selectEvaluationTargetsFromView( tx: PgTransaction, @@ -52,7 +54,7 @@ export async function countEvaluationTargetsFromView( .select({ count: count() }) .from(evaluationTargetsWithDepartments) .where(where); - + return res[0]?.count ?? 0; } @@ -102,9 +104,9 @@ export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { // 정렬 (View 테이블 기준) const orderBy = input.sort.length > 0 ? input.sort.map((item) => { - const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments]; - return item.desc ? desc(column) : asc(column); - }) + const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments]; + return item.desc ? desc(column) : asc(column); + }) : [desc(evaluationTargetsWithDepartments.createdAt)]; // 데이터 조회 - View 테이블 사용 @@ -196,7 +198,7 @@ export interface CreateEvaluationTargetInput { // service.ts 파일의 CreateEvaluationTargetInput 타입 수정 export interface CreateEvaluationTargetInput { evaluationYear: number - division: "OCEAN" | "SHIPYARD" + division: "PLANT" | "SHIP" vendorId: number materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK" adminComment?: string @@ -216,17 +218,16 @@ export async function createEvaluationTarget( input: CreateEvaluationTargetInput, createdBy: number ) { - - console.log(input,"input") + console.log(input, "input") try { return await db.transaction(async (tx) => { - // 벤더 정보 조회 (기존과 동일) + // 벤더 정보 조회 const vendor = await tx .select({ id: vendors.id, vendorCode: vendors.vendorCode, vendorName: vendors.vendorName, - country: vendors.country, + country: vendors.country, }) .from(vendors) .where(eq(vendors.id, input.vendorId)) @@ -238,7 +239,7 @@ export async function createEvaluationTarget( const vendorInfo = vendor[0]; - // 중복 체크 (기존과 동일) + // 중복 체크 const existing = await tx .select({ id: evaluationTargets.id }) .from(evaluationTargets) @@ -255,51 +256,57 @@ export async function createEvaluationTarget( throw new Error("이미 동일한 평가 대상이 존재합니다."); } - // 평가 대상 생성 (기존과 동일) + // 🔧 수정: 타입 추론 문제 해결 + const targetValues: typeof evaluationTargets.$inferInsert = { + evaluationYear: input.evaluationYear, + division: input.division, + vendorId: input.vendorId, + vendorCode: vendorInfo.vendorCode ?? '', + vendorName: vendorInfo.vendorName, + domesticForeign: vendorInfo.country === 'KR' ? 'DOMESTIC' : 'FOREIGN', + materialType: input.materialType, + status: 'PENDING', + adminComment: input.adminComment, + adminUserId: createdBy, + ldClaimCount: input.ldClaimCount ?? 0, + // 🔧 수정: decimal 타입은 숫자로 처리 + ldClaimAmount: input.ldClaimAmount?.toString() ?? '0', + ldClaimCurrency: input.ldClaimCurrency ?? 'KRW', + } + + console.log(targetValues) + + // 평가 대상 생성 const newEvaluationTarget = await tx .insert(evaluationTargets) - .values({ - evaluationYear: input.evaluationYear, - division: input.division, - vendorId: input.vendorId, - vendorCode: vendorInfo.vendorCode || "", - vendorName: vendorInfo.vendorName, - domesticForeign: vendorInfo.country === "KR" ? "DOMESTIC" : "FOREIGN", - materialType: input.materialType, - status: "PENDING", - adminComment: input.adminComment, - adminUserId: createdBy, - ldClaimCount: input.ldClaimCount || 0, - ldClaimAmount: input.ldClaimAmount?.toString() || "0", - ldClaimCurrency: input.ldClaimCurrency || "KRW", - }) + .values(targetValues) .returning({ id: evaluationTargets.id }); const evaluationTargetId = newEvaluationTarget[0].id; - // ✅ 담당자들 지정 (departmentNameFrom 추가) + // 담당자들 지정 if (input.reviewers && input.reviewers.length > 0) { - // 담당자들의 부서 정보 조회 const reviewerIds = input.reviewers.map(r => r.reviewerUserId); + + // 🔧 수정: SQL 배열 처리 개선 const reviewerInfos = await tx .select({ id: users.id, - departmentName: users.departmentName, // users 테이블에 부서명 필드가 있다고 가정 }) .from(users) - .where(sql`${users.id} = ANY(${reviewerIds})`); - - const reviewerAssignments = input.reviewers.map((reviewer) => { - const reviewerInfo = reviewerInfos.find(info => info.id === reviewer.reviewerUserId); - - return { - evaluationTargetId, - departmentCode: reviewer.departmentCode, - departmentNameFrom: reviewerInfo?.departmentName || null, // ✅ 실제 부서명 저장 - reviewerUserId: reviewer.reviewerUserId, - assignedBy: createdBy, - }; - }); + .where(inArray(users.id, reviewerIds)); // sql 대신 inArray 사용 + + const reviewerAssignments: typeof evaluationTargetReviewers.$inferInsert[] = + input.reviewers.map(r => { + const info = reviewerInfos.find(i => i.id === r.reviewerUserId); + return { + evaluationTargetId, + departmentCode: r.departmentCode, + departmentNameFrom: info?.departmentName ?? "TEST 부서", + reviewerUserId: r.reviewerUserId, + assignedBy: createdBy, + }; + }); await tx.insert(evaluationTargetReviewers).values(reviewerAssignments); } @@ -319,6 +326,253 @@ export async function createEvaluationTarget( } } +//업데이트 입력 타입 정의 +export interface UpdateEvaluationTargetInput { + id: number + adminComment?: string + consolidatedComment?: string + ldClaimCount?: number + ldClaimAmount?: number + ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY" + consensusStatus?: boolean | null + orderIsApproved?: boolean | null + procurementIsApproved?: boolean | null + qualityIsApproved?: boolean | null + designIsApproved?: boolean | null + csIsApproved?: boolean | null + // 담당자 이메일 변경 + orderReviewerEmail?: string + procurementReviewerEmail?: string + qualityReviewerEmail?: string + designReviewerEmail?: string + csReviewerEmail?: string +} + +export interface UpdateEvaluationTargetInput { + id: number + // 기본 정보 + adminComment?: string + consolidatedComment?: string + ldClaimCount?: number + ldClaimAmount?: number + ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY" + consensusStatus?: boolean | null + + // 각 부서별 평가 결과 + orderIsApproved?: boolean | null + procurementIsApproved?: boolean | null + qualityIsApproved?: boolean | null + designIsApproved?: boolean | null + csIsApproved?: boolean | null + + // 담당자 이메일 (사용자 ID로 변환됨) + orderReviewerEmail?: string + procurementReviewerEmail?: string + qualityReviewerEmail?: string + designReviewerEmail?: string + csReviewerEmail?: string +} + +export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) { + console.log(input, "update input") + + try { + const session = await auth() + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + return await db.transaction(async (tx) => { + // 평가 대상 존재 확인 + const existing = await tx + .select({ id: evaluationTargets.id }) + .from(evaluationTargets) + .where(eq(evaluationTargets.id, input.id)) + .limit(1) + + if (!existing.length) { + throw new Error("평가 대상을 찾을 수 없습니다.") + } + + // 1. 기본 정보 업데이트 + const updateFields: Partial = {} + + if (input.adminComment !== undefined) { + updateFields.adminComment = input.adminComment + } + if (input.consolidatedComment !== undefined) { + updateFields.consolidatedComment = input.consolidatedComment + } + if (input.ldClaimCount !== undefined) { + updateFields.ldClaimCount = input.ldClaimCount + } + if (input.ldClaimAmount !== undefined) { + updateFields.ldClaimAmount = input.ldClaimAmount.toString() + } + if (input.ldClaimCurrency !== undefined) { + updateFields.ldClaimCurrency = input.ldClaimCurrency + } + if (input.consensusStatus !== undefined) { + updateFields.consensusStatus = input.consensusStatus + } + + // 기본 정보가 있으면 업데이트 + if (Object.keys(updateFields).length > 0) { + updateFields.updatedAt = new Date() + + await tx + .update(evaluationTargets) + .set(updateFields) + .where(eq(evaluationTargets.id, input.id)) + } + + // 2. 담당자 정보 업데이트 + const reviewerUpdates = [ + { departmentCode: EVALUATION_DEPARTMENT_CODES.ORDER_EVAL, email: input.orderReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.PROCUREMENT_EVAL, email: input.procurementReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.QUALITY_EVAL, email: input.qualityReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.DESIGN_EVAL, email: input.designReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.CS_EVAL, email: input.csReviewerEmail }, + ] + + for (const update of reviewerUpdates) { + if (update.email !== undefined) { + // 기존 담당자 제거 + await tx + .delete(evaluationTargetReviewers) + .where( + and( + eq(evaluationTargetReviewers.evaluationTargetId, input.id), + eq(evaluationTargetReviewers.departmentCode, update.departmentCode) + ) + ) + + // 새 담당자 추가 (이메일이 있는 경우만) + if (update.email) { + // 이메일로 사용자 ID 조회 + const user = await tx + .select({ id: users.id }) + .from(users) + .where(eq(users.email, update.email)) + .limit(1) + + if (user.length > 0) { + await tx + .insert(evaluationTargetReviewers) + .values({ + evaluationTargetId: input.id, + departmentCode: update.departmentCode, + reviewerUserId: user[0].id, + assignedBy: session.user.id, + }) + } + } + } + } + + // 3. 평가 결과 업데이트 + const reviewUpdates = [ + { departmentCode: EVALUATION_DEPARTMENT_CODES.ORDER_EVAL, isApproved: input.orderIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.PROCUREMENT_EVAL, isApproved: input.procurementIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.QUALITY_EVAL, isApproved: input.qualityIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.DESIGN_EVAL, isApproved: input.designIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.CS_EVAL, isApproved: input.csIsApproved }, + ] + + for (const review of reviewUpdates) { + if (review.isApproved !== undefined) { + // 해당 부서의 담당자 조회 + const reviewer = await tx + .select({ + reviewerUserId: evaluationTargetReviewers.reviewerUserId + }) + .from(evaluationTargetReviewers) + .where( + and( + eq(evaluationTargetReviewers.evaluationTargetId, input.id), + eq(evaluationTargetReviewers.departmentCode, review.departmentCode) + ) + ) + .limit(1) + + if (reviewer.length > 0) { + // 기존 평가 결과 삭제 + await tx + .delete(evaluationTargetReviews) + .where( + and( + eq(evaluationTargetReviews.evaluationTargetId, input.id), + eq(evaluationTargetReviews.reviewerUserId, reviewer[0].reviewerUserId) + ) + ) + + // 새 평가 결과 추가 (null이 아닌 경우만) + if (review.isApproved !== null) { + await tx + .insert(evaluationTargetReviews) + .values({ + evaluationTargetId: input.id, + reviewerUserId: reviewer[0].reviewerUserId, + departmentCode: review.departmentCode, + isApproved: review.isApproved, + reviewedAt: new Date(), + }) + } + } + } + } + + // 4. 의견 일치 상태 및 전체 상태 자동 계산 + const currentReviews = await tx + .select({ + isApproved: evaluationTargetReviews.isApproved, + departmentCode: evaluationTargetReviews.departmentCode, + }) + .from(evaluationTargetReviews) + .where(eq(evaluationTargetReviews.evaluationTargetId, input.id)) + + console.log("Current reviews:", currentReviews) + + // 최소 3개 부서에서 평가가 완료된 경우 의견 일치 상태 계산 + if (currentReviews.length >= 3) { + const approvals = currentReviews.map(r => r.isApproved) + const allApproved = approvals.every(approval => approval === true) + const allRejected = approvals.every(approval => approval === false) + const hasConsensus = allApproved || allRejected + + let newStatus: "PENDING" | "CONFIRMED" | "EXCLUDED" = "PENDING" + if (hasConsensus) { + newStatus = allApproved ? "CONFIRMED" : "EXCLUDED" + } + + console.log("Auto-updating status:", { hasConsensus, newStatus, approvals }) + + await tx + .update(evaluationTargets) + .set({ + consensusStatus: hasConsensus, + status: newStatus, + confirmedAt: hasConsensus ? new Date() : null, + confirmedBy: hasConsensus ? session.user.id : null, + updatedAt: new Date() + }) + .where(eq(evaluationTargets.id, input.id)) + } + + return { + success: true, + message: "평가 대상이 성공적으로 수정되었습니다.", + } + }) + } catch (error) { + console.error("Error updating evaluation target:", error) + return { + success: false, + error: error instanceof Error ? error.message : "평가 대상 수정 중 오류가 발생했습니다.", + } + } +} + // 담당자 목록 조회 시 부서 정보도 함께 반환 export async function getAvailableReviewers(departmentCode?: string) { try { @@ -358,9 +612,9 @@ export async function getAvailableVendors(search?: string) { // 검색어가 있으면 적용 search ? or( - ilike(vendors.vendorCode, `%${search}%`), - ilike(vendors.vendorName, `%${search}%`) - ) + ilike(vendors.vendorCode, `%${search}%`), + ilike(vendors.vendorName, `%${search}%`) + ) : undefined ) ) @@ -392,4 +646,266 @@ export async function getDepartmentInfo() { key, }; }); +} + + +export async function confirmEvaluationTargets(targetIds: number[]) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." } + } + + if (targetIds.length === 0) { + return { success: false, error: "선택된 평가 대상이 없습니다." } + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 확정 가능한 대상들 확인 (PENDING 상태이면서 consensusStatus가 true인 것들) + const eligibleTargets = await tx + .select() + .from(evaluationTargets) + .where( + and( + inArray(evaluationTargets.id, targetIds), + eq(evaluationTargets.status, "PENDING"), + eq(evaluationTargets.consensusStatus, true) + ) + ) + + if (eligibleTargets.length === 0) { + throw new Error("확정 가능한 평가 대상이 없습니다. (의견 일치 상태인 대기중 항목만 확정 가능)") + } + + // 상태를 CONFIRMED로 변경 + const confirmedTargetIds = eligibleTargets.map(target => target.id) + await tx + .update(evaluationTargets) + .set({ + status: "CONFIRMED", + confirmedAt: new Date(), + confirmedBy: Number(session.user.id), + updatedAt: new Date() + }) + .where(inArray(evaluationTargets.id, confirmedTargetIds)) + + return confirmedTargetIds + }) + + + return { + success: true, + message: `${targetIds.length}개 평가 대상이 확정되었습니다.`, + confirmedCount: targetIds.length + } + + } catch (error) { + console.error("Error confirming evaluation targets:", error) + return { + success: false, + error: error instanceof Error ? error.message : "확정 처리 중 오류가 발생했습니다." + } + } +} + +export async function excludeEvaluationTargets(targetIds: number[]) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." } + } + + if (targetIds.length === 0) { + return { success: false, error: "선택된 평가 대상이 없습니다." } + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 제외 가능한 대상들 확인 (PENDING 상태인 것들) + const eligibleTargets = await tx + .select() + .from(evaluationTargets) + .where( + and( + inArray(evaluationTargets.id, targetIds), + eq(evaluationTargets.status, "PENDING") + ) + ) + + if (eligibleTargets.length === 0) { + throw new Error("제외 가능한 평가 대상이 없습니다. (대기중 상태인 항목만 제외 가능)") + } + + // 상태를 EXCLUDED로 변경 + const excludedTargetIds = eligibleTargets.map(target => target.id) + await tx + .update(evaluationTargets) + .set({ + status: "EXCLUDED", + updatedAt: new Date() + }) + .where(inArray(evaluationTargets.id, excludedTargetIds)) + + return excludedTargetIds + }) + + + return { + success: true, + message: `${targetIds.length}개 평가 대상이 제외되었습니다.`, + excludedCount: targetIds.length + } + + } catch (error) { + console.error("Error excluding evaluation targets:", error) + return { + success: false, + error: error instanceof Error ? error.message : "제외 처리 중 오류가 발생했습니다." + } + } +} + +export async function requestEvaluationReview(targetIds: number[], message?: string) { + try { + const session = await auth() + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." } + } + + if (targetIds.length === 0) { + return { success: false, error: "선택된 평가 대상이 없습니다." } + } + + // 선택된 평가 대상들과 담당자 정보 조회 + const targetsWithReviewers = await db + .select({ + id: evaluationTargets.id, + vendorCode: evaluationTargets.vendorCode, + vendorName: evaluationTargets.vendorName, + materialType: evaluationTargets.materialType, + evaluationYear: evaluationTargets.evaluationYear, + status: evaluationTargets.status, + reviewerEmail: users.email, + reviewerName: users.name, + departmentCode: evaluationTargetReviewers.departmentCode, + departmentName: evaluationTargetReviewers.departmentNameFrom, + }) + .from(evaluationTargets) + .leftJoin( + evaluationTargetReviewers, + eq(evaluationTargets.id, evaluationTargetReviewers.evaluationTargetId) + ) + .leftJoin( + users, + eq(evaluationTargetReviewers.reviewerUserId, users.id) + ) + .where( + and( + inArray(evaluationTargets.id, targetIds), + eq(evaluationTargets.status, "PENDING") + ) + ) + + if (targetsWithReviewers.length === 0) { + return { success: false, error: "의견 요청 가능한 평가 대상이 없습니다." } + } + + // 평가 대상별로 그룹화 + const targetGroups = targetsWithReviewers.reduce((acc, item) => { + if (!acc[item.id]) { + acc[item.id] = { + id: item.id, + vendorCode: item.vendorCode, + vendorName: item.vendorName, + materialType: item.materialType, + evaluationYear: item.evaluationYear, + reviewers: [] + } + } + + if (item.reviewerEmail) { + acc[item.id].reviewers.push({ + email: item.reviewerEmail, + name: item.reviewerName, + departmentCode: item.departmentCode, + departmentName: item.departmentName + }) + } + + return acc + }, {} as Record) + + const targets = Object.values(targetGroups) + + // 모든 담당자 이메일 수집 (중복 제거) + const reviewerEmails = new Set() + const reviewerInfo = new Map() + + targets.forEach(target => { + target.reviewers.forEach((reviewer: any) => { + if (reviewer.email) { + reviewerEmails.add(reviewer.email) + + if (!reviewerInfo.has(reviewer.email)) { + reviewerInfo.set(reviewer.email, { + name: reviewer.name || reviewer.email, + departments: [] + }) + } + + const info = reviewerInfo.get(reviewer.email)! + if (reviewer.departmentName && !info.departments.includes(reviewer.departmentName)) { + info.departments.push(reviewer.departmentName) + } + } + }) + }) + + if (reviewerEmails.size === 0) { + return { success: false, error: "담당자가 지정되지 않은 평가 대상입니다." } + } + + // 각 담당자에게 이메일 발송 + const emailPromises = Array.from(reviewerEmails).map(email => { + const reviewer = reviewerInfo.get(email)! + + return sendEmail({ + to: email, + subject: `벤더 평가 의견 요청 - ${targets.length}건`, + template: "evaluation-review-request", + context: { + requesterName: session.user.name || session.user.email, + reviewerName: reviewer.name, + targetCount: targets.length, + targets: targets.map(target => ({ + vendorCode: target.vendorCode, + vendorName: target.vendorName, + materialType: target.materialType, + evaluationYear: target.evaluationYear + })), + message: message || "", + reviewUrl: `${process.env.NEXTAUTH_URL}/evaluation-targets`, + requestDate: new Date().toLocaleString('ko-KR') + } + }) + }) + + await Promise.all(emailPromises) + + revalidatePath("/evaluation-targets") + return { + success: true, + message: `${reviewerEmails.size}명의 담당자에게 의견 요청 이메일이 발송되었습니다.`, + emailCount: reviewerEmails.size + } + + } catch (error) { + console.error("Error requesting evaluation review:", error) + return { + success: false, + error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다." + } + } } \ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx b/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx new file mode 100644 index 00000000..47af419d --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx @@ -0,0 +1,384 @@ +// evaluation-target-action-dialogs.tsx +"use client" + +import * as React from "react" +import { Loader2, AlertTriangle, Check, X, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" + +import { + confirmEvaluationTargets, + excludeEvaluationTargets, + requestEvaluationReview +} from "../service" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +// ---------------------------------------------------------------- +// 확정 컨펌 다이얼로그 +// ---------------------------------------------------------------- +interface ConfirmTargetsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function ConfirmTargetsDialog({ + open, + onOpenChange, + targets, + onSuccess +}: ConfirmTargetsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // 확정 가능한 대상들 (consensusStatus가 true인 것들) + const confirmableTargets = targets.filter( + t => t.status === "PENDING" && t.consensusStatus === true + ) + + const handleConfirm = async () => { + if (confirmableTargets.length === 0) return + + setIsLoading(true) + try { + const targetIds = confirmableTargets.map(t => t.id) + const result = await confirmEvaluationTargets(targetIds) + + if (result.success) { + toast.success(result.message) + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error) + } + } catch (error) { + toast.error("확정 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + + + + + + 평가 대상 확정 + + +
+

+ 선택된 {targets.length}개 항목 중{" "} + + {confirmableTargets.length}개 항목 + + 을 확정하시겠습니까? +

+ + {confirmableTargets.length !== targets.length && ( +
+

+ + 의견 일치 상태인 대기중 항목만 확정 가능합니다. + ({targets.length - confirmableTargets.length}개 항목 제외됨) +

+
+ )} + + {confirmableTargets.length > 0 && ( +
+
+ {confirmableTargets.slice(0, 5).map(target => ( +
+ + {target.vendorCode} + + {target.vendorName} +
+ ))} + {confirmableTargets.length > 5 && ( +

+ ...외 {confirmableTargets.length - 5}개 +

+ )} +
+
+ )} +
+
+
+ + 취소 + + {isLoading && } + 확정 ({confirmableTargets.length}) + + +
+
+ ) +} + +// ---------------------------------------------------------------- +// 제외 컨펌 다이얼로그 +// ---------------------------------------------------------------- +interface ExcludeTargetsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function ExcludeTargetsDialog({ + open, + onOpenChange, + targets, + onSuccess +}: ExcludeTargetsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // 제외 가능한 대상들 (PENDING 상태인 것들) + const excludableTargets = targets.filter(t => t.status === "PENDING") + + const handleExclude = async () => { + if (excludableTargets.length === 0) return + + setIsLoading(true) + try { + const targetIds = excludableTargets.map(t => t.id) + const result = await excludeEvaluationTargets(targetIds) + + if (result.success) { + toast.success(result.message) + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error) + } + } catch (error) { + toast.error("제외 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + + + + + + 평가 대상 제외 + + +
+

+ 선택된 {targets.length}개 항목 중{" "} + + {excludableTargets.length}개 항목 + + 을 제외하시겠습니까? +

+ + {excludableTargets.length !== targets.length && ( +
+

+ + 대기중 상태인 항목만 제외 가능합니다. + ({targets.length - excludableTargets.length}개 항목 제외됨) +

+
+ )} + + {excludableTargets.length > 0 && ( +
+
+ {excludableTargets.slice(0, 5).map(target => ( +
+ + {target.vendorCode} + + {target.vendorName} +
+ ))} + {excludableTargets.length > 5 && ( +

+ ...외 {excludableTargets.length - 5}개 +

+ )} +
+
+ )} +
+
+
+ + 취소 + + {isLoading && } + 제외 ({excludableTargets.length}) + + +
+
+ ) +} + +// ---------------------------------------------------------------- +// 의견 요청 다이얼로그 +// ---------------------------------------------------------------- +interface RequestReviewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function RequestReviewDialog({ + open, + onOpenChange, + targets, + onSuccess +}: RequestReviewDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [message, setMessage] = React.useState("") + + // 의견 요청 가능한 대상들 (PENDING 상태인 것들) + const reviewableTargets = targets.filter(t => t.status === "PENDING") + + // 담당자 이메일들 수집 + const reviewerEmails = React.useMemo(() => { + const emails = new Set() + reviewableTargets.forEach(target => { + if (target.orderReviewerEmail) emails.add(target.orderReviewerEmail) + if (target.procurementReviewerEmail) emails.add(target.procurementReviewerEmail) + if (target.qualityReviewerEmail) emails.add(target.qualityReviewerEmail) + if (target.designReviewerEmail) emails.add(target.designReviewerEmail) + if (target.csReviewerEmail) emails.add(target.csReviewerEmail) + }) + return Array.from(emails) + }, [reviewableTargets]) + + const handleRequestReview = async () => { + if (reviewableTargets.length === 0) return + + setIsLoading(true) + try { + const targetIds = reviewableTargets.map(t => t.id) + const result = await requestEvaluationReview(targetIds, message) + + if (result.success) { + toast.success(result.message) + onSuccess?.() + onOpenChange(false) + setMessage("") + } else { + toast.error(result.error) + } + } catch (error) { + toast.error("의견 요청 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + + + + + + 평가 의견 요청 + + + 선택된 평가 대상에 대한 의견을 담당자들에게 요청합니다. + + + +
+ {/* 요약 정보 */} +
+
+

+ 요청 대상: {reviewableTargets.length}개 평가 항목 +

+

+ 받는 사람: {reviewerEmails.length}명의 담당자 +

+
+
+ + {/* 메시지 입력 */} +
+ +