From 90f79a7a691943a496f67f01c1e493256070e4de Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 01:44:45 +0000 Subject: (대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/evaluation/service.ts | 1348 +++++++++++++++----- lib/evaluation/table/evaluation-columns.tsx | 213 ++-- lib/evaluation/table/evaluation-details-dialog.tsx | 366 ++++++ lib/evaluation/table/evaluation-table.tsx | 11 +- .../table/periodic-evaluation-action-dialogs.tsx | 231 +++- .../table/periodic-evaluation-finalize-dialogs.tsx | 305 +++++ .../table/periodic-evaluations-toolbar-actions.tsx | 179 +-- 7 files changed, 2100 insertions(+), 553 deletions(-) create mode 100644 lib/evaluation/table/evaluation-details-dialog.tsx create mode 100644 lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx (limited to 'lib/evaluation') diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 19e41dff..67a692ab 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -1,389 +1,1047 @@ 'use server' import db from "@/db/db" -import { +import { evaluationSubmissions, - periodicEvaluationsView, - type PeriodicEvaluationView + evaluationTargetReviewers, + evaluationTargets, + periodicEvaluations, + periodicEvaluationsView, + regEvalCriteria, + regEvalCriteriaDetails, + reviewerEvaluationDetails, + reviewerEvaluations, + users, + type PeriodicEvaluationView } from "@/db/schema" -import { - and, - asc, - count, - desc, - ilike, - or, sql , eq, avg, - type SQL +import { + and, + asc, + count, + desc, + ilike, + or, sql, eq, avg, inArray, + type SQL } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation"; +import { sendEmail } from "../mail/sendEmail" +import { revalidatePath } from "next/cache" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) { - try { + try { - const offset = (input.page - 1) * input.perPage; - - // ✅ getEvaluationTargets 방식과 동일한 필터링 처리 - // 1) 고급 필터 조건 - let advancedWhere: SQL | undefined = undefined; - if (input.filters && input.filters.length > 0) { - advancedWhere = filterColumns({ - table: periodicEvaluationsView, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - }); - } - - // 2) 기본 필터 조건 - let basicWhere: SQL | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: periodicEvaluationsView, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - }); - } - - // 3) 글로벌 검색 조건 - let globalWhere: SQL | undefined = undefined; - if (input.search) { - const s = `%${input.search}%`; - - const validSearchConditions: SQL[] = []; - - // 벤더 정보로 검색 - const vendorCodeCondition = ilike(periodicEvaluationsView.vendorCode, s); - if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); - - const vendorNameCondition = ilike(periodicEvaluationsView.vendorName, s); - if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); - - // 평가 관련 코멘트로 검색 - const evaluationNoteCondition = ilike(periodicEvaluationsView.evaluationNote, s); - if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition); - - const adminCommentCondition = ilike(periodicEvaluationsView.evaluationTargetAdminComment, s); - if (adminCommentCondition) validSearchConditions.push(adminCommentCondition); - - const consolidatedCommentCondition = ilike(periodicEvaluationsView.evaluationTargetConsolidatedComment, s); - if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition); - - // 최종 확정자 이름으로 검색 - const finalizedByUserNameCondition = ilike(periodicEvaluationsView.finalizedByUserName, s); - if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition); - - if (validSearchConditions.length > 0) { - globalWhere = or(...validSearchConditions); - } - } - - // ✅ getEvaluationTargets 방식과 동일한 WHERE 조건 생성 - const whereConditions: SQL[] = []; - - if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); - if (globalWhere) whereConditions.push(globalWhere); - - const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // ✅ getEvaluationTargets 방식과 동일한 전체 데이터 수 조회 - const totalResult = await db - .select({ count: count() }) - .from(periodicEvaluationsView) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - - if (total === 0) { - return { data: [], pageCount: 0, total: 0 }; - } - - console.log("Total periodic evaluations:", total); - - // ✅ getEvaluationTargets 방식과 동일한 정렬 및 페이징 처리된 데이터 조회 - const orderByColumns = input.sort.map((sort) => { - const column = sort.id as keyof typeof periodicEvaluationsView.$inferSelect; - return sort.desc ? desc(periodicEvaluationsView[column]) : asc(periodicEvaluationsView[column]); + const offset = (input.page - 1) * input.perPage; + + // ✅ getEvaluationTargets 방식과 동일한 필터링 처리 + // 1) 고급 필터 조건 + let advancedWhere: SQL | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: periodicEvaluationsView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', }); - - if (orderByColumns.length === 0) { - orderByColumns.push(desc(periodicEvaluationsView.createdAt)); + } + + // 2) 기본 필터 조건 + let basicWhere: SQL | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: periodicEvaluationsView, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + }); + } + + // 3) 글로벌 검색 조건 + let globalWhere: SQL | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL[] = []; + + // 벤더 정보로 검색 + const vendorCodeCondition = ilike(periodicEvaluationsView.vendorCode, s); + if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); + + const vendorNameCondition = ilike(periodicEvaluationsView.vendorName, s); + if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); + + // 평가 관련 코멘트로 검색 + const evaluationNoteCondition = ilike(periodicEvaluationsView.evaluationNote, s); + if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition); + + const adminCommentCondition = ilike(periodicEvaluationsView.evaluationTargetAdminComment, s); + if (adminCommentCondition) validSearchConditions.push(adminCommentCondition); + + const consolidatedCommentCondition = ilike(periodicEvaluationsView.evaluationTargetConsolidatedComment, s); + if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition); + + // 최종 확정자 이름으로 검색 + const finalizedByUserNameCondition = ilike(periodicEvaluationsView.finalizedByUserName, s); + if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); } - - const periodicEvaluationsData = await db - .select() - .from(periodicEvaluationsView) - .where(finalWhere) - .orderBy(...orderByColumns) - .limit(input.perPage) - .offset(offset); - - const pageCount = Math.ceil(total / input.perPage); + } - console.log(periodicEvaluationsData,"periodicEvaluationsData") - - return { data: periodicEvaluationsData, pageCount, total }; - } catch (err) { - console.error("Error in getPeriodicEvaluations:", err); - // ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함) + // ✅ getEvaluationTargets 방식과 동일한 WHERE 조건 생성 + const whereConditions: SQL[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (basicWhere) whereConditions.push(basicWhere); + if (globalWhere) whereConditions.push(globalWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // ✅ getEvaluationTargets 방식과 동일한 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(periodicEvaluationsView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { return { data: [], pageCount: 0, total: 0 }; } - } - export interface PeriodicEvaluationsStats { - total: number - pendingSubmission: number - submitted: number - inReview: number - reviewCompleted: number - finalized: number - averageScore: number | null - completionRate: number - averageFinalScore: number | null - documentsSubmittedCount: number - documentsNotSubmittedCount: number - reviewProgress: { - totalReviewers: number - completedReviewers: number - pendingReviewers: number - reviewCompletionRate: number + console.log("Total periodic evaluations:", total); + + // ✅ getEvaluationTargets 방식과 동일한 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof periodicEvaluationsView.$inferSelect; + return sort.desc ? desc(periodicEvaluationsView[column]) : asc(periodicEvaluationsView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(periodicEvaluationsView.createdAt)); } + + const periodicEvaluationsData = await db + .select() + .from(periodicEvaluationsView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + console.log(periodicEvaluationsData, "periodicEvaluationsData") + + return { data: periodicEvaluationsData, pageCount, total }; + } catch (err) { + console.error("Error in getPeriodicEvaluations:", err); + // ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함) + return { data: [], pageCount: 0, total: 0 }; } - - export async function getPeriodicEvaluationsStats(evaluationYear: number): Promise { - try { - // 기본 WHERE 조건: 해당 연도의 평가만 - const baseWhere = eq(periodicEvaluationsView.evaluationYear, evaluationYear) - - // 1. 전체 통계 조회 - const totalStatsResult = await db - .select({ - total: count(), - averageScore: avg(periodicEvaluationsView.totalScore), - averageFinalScore: avg(periodicEvaluationsView.finalScore), - }) - .from(periodicEvaluationsView) - .where(baseWhere) - - const totalStats = totalStatsResult[0] || { - total: 0, - averageScore: null, - averageFinalScore: null +} + +export interface PeriodicEvaluationsStats { + total: number + pendingSubmission: number + submitted: number + inReview: number + reviewCompleted: number + finalized: number + averageScore: number | null + completionRate: number + averageFinalScore: number | null + documentsSubmittedCount: number + documentsNotSubmittedCount: number + reviewProgress: { + totalReviewers: number + completedReviewers: number + pendingReviewers: number + reviewCompletionRate: number + } +} + +export async function getPeriodicEvaluationsStats(evaluationYear: number): Promise { + try { + // 기본 WHERE 조건: 해당 연도의 평가만 + const baseWhere = eq(periodicEvaluationsView.evaluationYear, evaluationYear) + + // 1. 전체 통계 조회 + const totalStatsResult = await db + .select({ + total: count(), + averageScore: avg(periodicEvaluationsView.totalScore), + averageFinalScore: avg(periodicEvaluationsView.finalScore), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + + const totalStats = totalStatsResult[0] || { + total: 0, + averageScore: null, + averageFinalScore: null + } + + // 2. 상태별 카운트 조회 + const statusStatsResult = await db + .select({ + status: periodicEvaluationsView.status, + count: count(), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + .groupBy(periodicEvaluationsView.status) + + // 상태별 카운트를 객체로 변환 + const statusCounts = statusStatsResult.reduce((acc, item) => { + acc[item.status] = item.count + return acc + }, {} as Record) + + // 3. 문서 제출 상태 통계 + const documentStatsResult = await db + .select({ + documentsSubmitted: periodicEvaluationsView.documentsSubmitted, + count: count(), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + .groupBy(periodicEvaluationsView.documentsSubmitted) + + const documentCounts = documentStatsResult.reduce((acc, item) => { + if (item.documentsSubmitted) { + acc.submitted = item.count + } else { + acc.notSubmitted = item.count } - - // 2. 상태별 카운트 조회 - const statusStatsResult = await db - .select({ - status: periodicEvaluationsView.status, - count: count(), - }) - .from(periodicEvaluationsView) - .where(baseWhere) - .groupBy(periodicEvaluationsView.status) - - // 상태별 카운트를 객체로 변환 - const statusCounts = statusStatsResult.reduce((acc, item) => { - acc[item.status] = item.count - return acc - }, {} as Record) - - // 3. 문서 제출 상태 통계 - const documentStatsResult = await db - .select({ - documentsSubmitted: periodicEvaluationsView.documentsSubmitted, - count: count(), + return acc + }, { submitted: 0, notSubmitted: 0 }) + + // 4. 리뷰어 진행 상황 통계 + const reviewProgressResult = await db + .select({ + totalReviewers: sql`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'), + completedReviewers: sql`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'), + pendingReviewers: sql`SUM(${periodicEvaluationsView.pendingReviewers})`.as('pending_reviewers'), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + + const reviewProgress = reviewProgressResult[0] || { + totalReviewers: 0, + completedReviewers: 0, + pendingReviewers: 0, + } + + // 5. 완료율 계산 + const finalizedCount = statusCounts['FINALIZED'] || 0 + const totalCount = totalStats.total + const completionRate = totalCount > 0 ? Math.round((finalizedCount / totalCount) * 100) : 0 + + // 6. 리뷰 완료율 계산 + const reviewCompletionRate = reviewProgress.totalReviewers > 0 + ? Math.round((reviewProgress.completedReviewers / reviewProgress.totalReviewers) * 100) + : 0 + + // 7. 평균 점수 포맷팅 (소수점 1자리) + const formatScore = (score: string | number | null): number | null => { + if (score === null || score === undefined) return null + return Math.round(Number(score) * 10) / 10 + } + + return { + total: totalCount, + pendingSubmission: statusCounts['PENDING_SUBMISSION'] || 0, + submitted: statusCounts['SUBMITTED'] || 0, + inReview: statusCounts['IN_REVIEW'] || 0, + reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0, + finalized: finalizedCount, + averageScore: formatScore(totalStats.averageScore), + averageFinalScore: formatScore(totalStats.averageFinalScore), + completionRate, + documentsSubmittedCount: documentCounts.submitted, + documentsNotSubmittedCount: documentCounts.notSubmitted, + reviewProgress: { + totalReviewers: reviewProgress.totalReviewers, + completedReviewers: reviewProgress.completedReviewers, + pendingReviewers: reviewProgress.pendingReviewers, + reviewCompletionRate, + }, + } + + } catch (error) { + console.error('Error in getPeriodicEvaluationsStats:', error) + // 에러 발생 시 기본값 반환 + return { + total: 0, + pendingSubmission: 0, + submitted: 0, + inReview: 0, + reviewCompleted: 0, + finalized: 0, + averageScore: null, + averageFinalScore: null, + completionRate: 0, + documentsSubmittedCount: 0, + documentsNotSubmittedCount: 0, + reviewProgress: { + totalReviewers: 0, + completedReviewers: 0, + pendingReviewers: 0, + reviewCompletionRate: 0, + }, + } + } +} + + + +interface RequestDocumentsData { + periodicEvaluationId: number + companyId: number + evaluationYear: number + evaluationRound: string + message: string +} + +export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) { + try { + // 각 평가에 대해 evaluationSubmissions 레코드 생성 + const submissions = await Promise.all( + data.map(async (item) => { + // 이미 해당 periodicEvaluationId와 companyId로 생성된 submission이 있는지 확인 + const existingSubmission = await db.query.evaluationSubmissions.findFirst({ + where: and( + eq(evaluationSubmissions.periodicEvaluationId, item.periodicEvaluationId), + eq(evaluationSubmissions.companyId, item.companyId) + ) }) - .from(periodicEvaluationsView) - .where(baseWhere) - .groupBy(periodicEvaluationsView.documentsSubmitted) - - const documentCounts = documentStatsResult.reduce((acc, item) => { - if (item.documentsSubmitted) { - acc.submitted = item.count + + if (existingSubmission) { + // 이미 존재하면 reviewComments만 업데이트 + const [updated] = await db + .update(evaluationSubmissions) + .set({ + reviewComments: item.message, + updatedAt: new Date() + }) + .where(eq(evaluationSubmissions.id, existingSubmission.id)) + .returning() + + return updated } else { - acc.notSubmitted = item.count + // 새로 생성 + const [created] = await db + .insert(evaluationSubmissions) + .values({ + periodicEvaluationId: item.periodicEvaluationId, + companyId: item.companyId, + evaluationYear: item.evaluationYear, + evaluationRound: item.evaluationRound, + submissionStatus: 'draft', // 기본값 + reviewComments: item.message, + // 진행률 관련 필드들은 기본값 0으로 설정됨 + totalGeneralItems: 0, + completedGeneralItems: 0, + totalEsgItems: 0, + completedEsgItems: 0, + isActive: true + }) + .returning() + + return created } - return acc - }, { submitted: 0, notSubmitted: 0 }) - - // 4. 리뷰어 진행 상황 통계 - const reviewProgressResult = await db + }) + ) + + + return { + success: true, + message: `${submissions.length}개 업체에 자료 요청이 완료되었습니다.`, + submissions + } + + } catch (error) { + console.error("Error requesting documents from vendors:", error) + return { + success: false, + message: "자료 요청 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error" + } + } +} + +// 기존 요청 상태 확인 함수 추가 +export async function checkExistingSubmissions(periodicEvaluationIds: number[]) { + try { + const existingSubmissions = await db.query.evaluationSubmissions.findMany({ + where: (submissions) => { + // periodicEvaluationIds 배열에 포함된 ID들을 확인 + return periodicEvaluationIds.length === 1 + ? eq(submissions.periodicEvaluationId, periodicEvaluationIds[0]) + : periodicEvaluationIds.length > 1 + ? or(...periodicEvaluationIds.map(id => eq(submissions.periodicEvaluationId, id))) + : eq(submissions.id, -1) // 빈 배열인 경우 결과 없음 + }, + columns: { + id: true, + periodicEvaluationId: true, + companyId: true, + createdAt: true, + reviewComments: true + } + }) + + return existingSubmissions + } catch (error) { + console.error("Error checking existing submissions:", error) + return [] + } +} + + +// ================================================================ +// 타입 정의 +// ================================================================ +interface ReviewerInfo { + id: number + name: string + email: string + deptName: string | null + departmentCode: string + evaluationTargetId: number + evaluationTargetReviewerId: number +} + +interface ReviewerEvaluationRequestData { + periodicEvaluationId: number + evaluationTargetReviewerId: number + message: string +} + +// ================================================================ +// 1. 평가 대상별 리뷰어 정보 가져오기 +// ================================================================ +export async function getReviewersForEvaluations( + evaluationTargetIds: number[] +): Promise { + try { + if (evaluationTargetIds.length === 0) { + return [] + } + + // evaluation_target_reviewers와 users 테이블 조인 + const reviewers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + deptName: users.deptName, + departmentCode: evaluationTargetReviewers.departmentCode, + evaluationTargetId: evaluationTargetReviewers.evaluationTargetId, + evaluationTargetReviewerId: evaluationTargetReviewers.id, + }) + .from(evaluationTargetReviewers) + .innerJoin(users, eq(evaluationTargetReviewers.reviewerUserId, users.id)) + .where( + and( + inArray(evaluationTargetReviewers.evaluationTargetId, evaluationTargetIds), + eq(users.isActive, true) // 활성 사용자만 + ) + ) + .orderBy(evaluationTargetReviewers.evaluationTargetId, users.name) + + return reviewers + } catch (error) { + console.error('Error fetching reviewers for evaluations:', error) + throw new Error('평가자 정보를 가져오는데 실패했습니다.') + } +} +// ================================================================ +// 2. 리뷰어 평가 요청 생성 및 알림 발송 +// ================================================================ +export async function createReviewerEvaluationsRequest( + requestData: ReviewerEvaluationRequestData[] +): Promise<{ success: boolean; message: string }> { + try { + if (requestData.length === 0) { + return { + success: false, + message: "요청할 평가 데이터가 없습니다." + } + } + + console.log('평가 요청 데이터:', requestData) + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. 기존 reviewerEvaluations 확인 (중복 방지) + const existingEvaluations = await tx .select({ - totalReviewers: sql`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'), - completedReviewers: sql`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'), - pendingReviewers: sql`SUM(${periodicEvaluationsView.pendingReviewers})`.as('pending_reviewers'), + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + evaluationTargetReviewerId: reviewerEvaluations.evaluationTargetReviewerId, }) - .from(periodicEvaluationsView) - .where(baseWhere) - - const reviewProgress = reviewProgressResult[0] || { - totalReviewers: 0, - completedReviewers: 0, - pendingReviewers: 0, + .from(reviewerEvaluations) + .where( + and( + inArray( + reviewerEvaluations.periodicEvaluationId, + requestData.map(r => r.periodicEvaluationId) + ), + inArray( + reviewerEvaluations.evaluationTargetReviewerId, + requestData.map(r => r.evaluationTargetReviewerId) + ) + ) + ) + + // 2. 중복되지 않는 새로운 평가 요청만 필터링 + const newRequestData = requestData.filter(request => + !existingEvaluations.some(existing => + existing.periodicEvaluationId === request.periodicEvaluationId && + existing.evaluationTargetReviewerId === request.evaluationTargetReviewerId + ) + ) + + if (newRequestData.length === 0) { + throw new Error("모든 평가 요청이 이미 생성되어 있습니다.") } - - // 5. 완료율 계산 - const finalizedCount = statusCounts['FINALIZED'] || 0 - const totalCount = totalStats.total - const completionRate = totalCount > 0 ? Math.round((finalizedCount / totalCount) * 100) : 0 - - // 6. 리뷰 완료율 계산 - const reviewCompletionRate = reviewProgress.totalReviewers > 0 - ? Math.round((reviewProgress.completedReviewers / reviewProgress.totalReviewers) * 100) - : 0 - - // 7. 평균 점수 포맷팅 (소수점 1자리) - const formatScore = (score: string | number | null): number | null => { - if (score === null || score === undefined) return null - return Math.round(Number(score) * 10) / 10 + + console.log(`새로 생성할 평가 요청: ${newRequestData.length}개`) + + // 3. reviewerEvaluations 테이블에 레코드 생성 + const reviewerEvaluationInsertData = newRequestData.map(request => ({ + periodicEvaluationId: request.periodicEvaluationId, + evaluationTargetReviewerId: request.evaluationTargetReviewerId, + isCompleted: false, + // 기본값들 + processScore: null, + priceScore: null, + deliveryScore: null, + selfEvaluationScore: null, + participationBonus: "0", + qualityDeduction: "0", + totalScore: null, + grade: null, + completedAt: null, + reviewerComment: null, + })) + + const insertedEvaluations = await tx.insert(reviewerEvaluations).values(reviewerEvaluationInsertData).returning({ id: reviewerEvaluations.id }) + console.log(`reviewerEvaluations 레코드 생성 완료: ${insertedEvaluations.length}개`) + + // 4. 이메일 발송을 위한 상세 정보 수집 + try { + await sendEvaluationRequestEmails(tx, newRequestData, requestData[0]?.message || "") + } catch (emailError) { + console.error('이메일 발송 중 오류:', emailError) + // 이메일 발송 실패해도 전체 트랜잭션은 성공으로 처리 } - + }) + + const totalReviewers = [...new Set(requestData.map(r => r.evaluationTargetReviewerId))].length + const totalEvaluations = [...new Set(requestData.map(r => r.periodicEvaluationId))].length + + return { + success: true, + message: `${totalEvaluations}개 평가에 대해 ${totalReviewers}명의 평가자에게 요청이 발송되었습니다.` + } + + } catch (error) { + console.error('Error creating reviewer evaluation requests:', error) + return { + success: false, + message: error instanceof Error ? error.message : "평가 요청 생성 중 오류가 발생했습니다." + } + } +} + + +const getDepartmentLabel = (code: string): string => { + return DEPARTMENT_CODE_LABELS[code as keyof typeof DEPARTMENT_CODE_LABELS] || code +} + +// ================================================================ +// 이메일 발송 헬퍼 함수 (완전 새로 작성) +// ================================================================ +async function sendEvaluationRequestEmails( + tx: any, + requestData: ReviewerEvaluationRequestData[], + message: string +) { + try { + + // 1. 평가 정보 수집 (periodicEvaluations + evaluationTargets 조인) + const evaluationIds = [...new Set(requestData.map(r => r.periodicEvaluationId))] + + const evaluationDetails = await tx + .select({ + periodicEvaluationId: periodicEvaluations.id, + evaluationTargetId: periodicEvaluations.evaluationTargetId, + evaluationYear: evaluationTargets.evaluationYear, + evaluationPeriod: periodicEvaluations.evaluationPeriod, + vendorCode: evaluationTargets.vendorCode, + vendorName: evaluationTargets.vendorName, + division: evaluationTargets.division, + materialType: evaluationTargets.materialType, + domesticForeign: evaluationTargets.domesticForeign, + submissionDeadline: periodicEvaluations.submissionDeadline, + }) + .from(periodicEvaluations) + .innerJoin(evaluationTargets, eq(periodicEvaluations.evaluationTargetId, evaluationTargets.id)) + .where(inArray(periodicEvaluations.id, evaluationIds)) + + console.log('평가 상세 정보:', evaluationDetails) + + // 2. 리뷰어 정보 수집 + const reviewerIds = [...new Set(requestData.map(r => r.evaluationTargetReviewerId))] + console.log('리뷰어 ID들:', reviewerIds) + + const reviewerDetails = await tx + .select({ + evaluationTargetReviewerId: evaluationTargetReviewers.id, + evaluationTargetId: evaluationTargetReviewers.evaluationTargetId, + departmentCode: evaluationTargetReviewers.departmentCode, + reviewerUserId: evaluationTargetReviewers.reviewerUserId, + userName: users.name, + userEmail: users.email, + deptName: users.deptName, + }) + .from(evaluationTargetReviewers) + .innerJoin(users, eq(evaluationTargetReviewers.reviewerUserId, users.id)) + .where(inArray(evaluationTargetReviewers.id, reviewerIds)) + + console.log('리뷰어 상세 정보:', reviewerDetails) + + // 3. 평가별로 그룹핑 (각 평가에 대한 리뷰어들) + const evaluationGroups = evaluationDetails.map(evaluation => { + const relatedRequests = requestData.filter(req => req.periodicEvaluationId === evaluation.periodicEvaluationId) + const evaluationReviewers = relatedRequests.map(req => { + const reviewer = reviewerDetails.find(r => r.evaluationTargetReviewerId === req.evaluationTargetReviewerId) + return { + ...reviewer, + departmentLabel: getDepartmentLabel(reviewer?.departmentCode || ''), + } + }).filter(Boolean) + return { - total: totalCount, - pendingSubmission: statusCounts['PENDING_SUBMISSION'] || 0, - submitted: statusCounts['SUBMITTED'] || 0, - inReview: statusCounts['IN_REVIEW'] || 0, - reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0, - finalized: finalizedCount, - averageScore: formatScore(totalStats.averageScore), - averageFinalScore: formatScore(totalStats.averageFinalScore), - completionRate, - documentsSubmittedCount: documentCounts.submitted, - documentsNotSubmittedCount: documentCounts.notSubmitted, - reviewProgress: { - totalReviewers: reviewProgress.totalReviewers, - completedReviewers: reviewProgress.completedReviewers, - pendingReviewers: reviewProgress.pendingReviewers, - reviewCompletionRate, - }, + ...evaluation, + reviewers: evaluationReviewers, + relatedRequests } - - } catch (error) { - console.error('Error in getPeriodicEvaluationsStats:', error) - // 에러 발생 시 기본값 반환 - return { - total: 0, - pendingSubmission: 0, - submitted: 0, - inReview: 0, - reviewCompleted: 0, - finalized: 0, - averageScore: null, - averageFinalScore: null, - completionRate: 0, - documentsSubmittedCount: 0, - documentsNotSubmittedCount: 0, - reviewProgress: { - totalReviewers: 0, - completedReviewers: 0, - pendingReviewers: 0, - reviewCompletionRate: 0, - }, + }) + + console.log('평가 그룹:', evaluationGroups) + + // 4. 각 리뷰어에게 개별 이메일 발송 + const emailPromises = [] + + for (const group of evaluationGroups) { + for (const reviewer of group.reviewers) { + if (!reviewer?.userEmail) { + console.log(`이메일 주소가 없는 리뷰어 스킵: ${reviewer?.userName}`) + continue + } + + // 해당 리뷰어를 제외한 다른 리뷰어들 + const otherReviewers = group.reviewers.filter(r => r?.evaluationTargetReviewerId !== reviewer.evaluationTargetReviewerId) + + console.log(`${reviewer.userName}(${reviewer.userEmail})에게 이메일 발송 준비`) + + const emailPromise = sendEmail({ + to: reviewer.userEmail, + subject: `[평가 요청] ${group.vendorName} - ${group.evaluationYear}년 ${group.evaluationPeriod} 정기평가`, + template: "evaluation-request", + context: { + language: "ko", + reviewerName: reviewer.userName, + departmentLabel: reviewer.departmentLabel, + evaluation: { + vendorName: group.vendorName, + vendorCode: group.vendorCode, + evaluationYear: group.evaluationYear, + evaluationPeriod: group.evaluationPeriod, + division: group.division, + materialType: group.materialType, + domesticForeign: group.domesticForeign, + submissionDeadline: group.submissionDeadline ? new Date(group.submissionDeadline).toLocaleDateString('ko-KR') : null, + }, + otherReviewers: otherReviewers.map(r => ({ + name: r?.userName, + department: r?.departmentLabel, + email: r?.userEmail + })).filter(r => r.name), + message: message || "협력업체 정기평가를 진행해 주시기 바랍니다.", + evaluationUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evaluations/${group.periodicEvaluationId}/review` + }, + }).catch(error => { + console.error(`${reviewer.userEmail}에게 이메일 발송 실패:`, error) + return null + }) + + emailPromises.push(emailPromise) } } + + // 5. 모든 이메일 발송 대기 + const emailResults = await Promise.allSettled(emailPromises) + const successCount = emailResults.filter(result => result.status === 'fulfilled').length + const failureCount = emailResults.filter(result => result.status === 'rejected').length + + console.log(`이메일 발송 완료: 성공 ${successCount}개, 실패 ${failureCount}개`) + + if (failureCount > 0) { + console.error('실패한 이메일들:', emailResults.filter(r => r.status === 'rejected').map(r => r.reason)) + } + + } catch (error) { + console.error('Error sending evaluation request emails:', error) + throw error // 이메일 발송 실패도 에러로 처리하려면 throw, 아니면 console.error만 } +} +// ================================================================ +// 3. 리뷰어별 평가 완료 상태 확인 (선택적 기능) +// ================================================================ +export async function getReviewerEvaluationStatus( + periodicEvaluationIds: number[] +): Promise> { + try { + if (periodicEvaluationIds.length === 0) { + return [] + } + const evaluationStatus = await db + .select({ + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + totalReviewers: db.$count(reviewerEvaluations.id), + completedReviewers: db.$count( + reviewerEvaluations.id, + eq(reviewerEvaluations.isCompleted, true) + ), + }) + .from(reviewerEvaluations) + .where(inArray(reviewerEvaluations.periodicEvaluationId, periodicEvaluationIds)) + .groupBy(reviewerEvaluations.periodicEvaluationId) + return evaluationStatus.map(status => ({ + ...status, + completionRate: status.totalReviewers > 0 + ? Math.round((status.completedReviewers / status.totalReviewers) * 100) + : 0 + })) - interface RequestDocumentsData { - periodicEvaluationId: number - companyId: number - evaluationYear: number - evaluationRound: string - message: string + } catch (error) { + console.error('Error fetching reviewer evaluation status:', error) + throw new Error('평가 완료 상태를 가져오는데 실패했습니다.') } - - export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) { - try { - // 각 평가에 대해 evaluationSubmissions 레코드 생성 - const submissions = await Promise.all( - data.map(async (item) => { - // 이미 해당 periodicEvaluationId와 companyId로 생성된 submission이 있는지 확인 - const existingSubmission = await db.query.evaluationSubmissions.findFirst({ - where: and( - eq(evaluationSubmissions.periodicEvaluationId, item.periodicEvaluationId), - eq(evaluationSubmissions.companyId, item.companyId) - ) +} + +// 평가 확정 데이터 타입 +interface FinalizeEvaluationData { + id: number + finalScore: number + finalGrade: "S" | "A" | "B" | "C" | "D" +} + +/** + * 평가를 최종 확정합니다 + */ +export async function finalizeEvaluations( + evaluationData: FinalizeEvaluationData[] +) { + try { + // 현재 사용자 정보 가져오기 + const currentUser = await getCurrentUser() + if (!currentUser) { + throw new Error("인증이 필요합니다") + } + + // 트랜잭션으로 여러 평가를 한번에 처리 + await db.transaction(async (tx) => { + const now = new Date() + + // 각 평가를 순차적으로 처리 + for (const evaluation of evaluationData) { + // 1. 평가 상태가 REVIEW_COMPLETED인지 확인 + const existingEvaluation = await tx + .select({ + id: periodicEvaluations.id, + status: periodicEvaluations.status, }) - - if (existingSubmission) { - // 이미 존재하면 reviewComments만 업데이트 - const [updated] = await db - .update(evaluationSubmissions) - .set({ - reviewComments: item.message, - updatedAt: new Date() - }) - .where(eq(evaluationSubmissions.id, existingSubmission.id)) - .returning() - - return updated - } else { - // 새로 생성 - const [created] = await db - .insert(evaluationSubmissions) - .values({ - periodicEvaluationId: item.periodicEvaluationId, - companyId: item.companyId, - evaluationYear: item.evaluationYear, - evaluationRound: item.evaluationRound, - submissionStatus: 'draft', // 기본값 - reviewComments: item.message, - // 진행률 관련 필드들은 기본값 0으로 설정됨 - totalGeneralItems: 0, - completedGeneralItems: 0, - totalEsgItems: 0, - completedEsgItems: 0, - isActive: true - }) - .returning() - - return created - } - }) - ) - - - return { - success: true, - message: `${submissions.length}개 업체에 자료 요청이 완료되었습니다.`, - submissions + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, evaluation.id)) + .limit(1) + + if (existingEvaluation.length === 0) { + throw new Error(`평가를 찾을 수 없습니다: ID ${evaluation.id}`) + } + + if (existingEvaluation[0].status !== "REVIEW_COMPLETED") { + throw new Error( + `평가 ${evaluation.id}는 검토 완료 상태가 아닙니다. 현재 상태: ${existingEvaluation[0].status}` + ) + } + + // 2. 평가를 최종 확정으로 업데이트 + await tx + .update(periodicEvaluations) + .set({ + finalScore: evaluation.finalScore.toString(), + finalGrade: evaluation.finalGrade, + status: "FINALIZED", + finalizedAt: now, + finalizedBy: currentUser.id, + updatedAt: now, + }) + .where(eq(periodicEvaluations.id, evaluation.id)) } - - } catch (error) { - console.error("Error requesting documents from vendors:", error) - return { - success: false, - message: "자료 요청 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error" + }) + + revalidatePath("/evcp/evaluation") + revalidatePath("/procurement/evaluation") + + return { + success: true, + message: `${evaluationData.length}건의 평가가 성공적으로 확정되었습니다`, + } + } catch (error) { + console.error("Error finalizing evaluations:", error) + throw new Error( + error instanceof Error + ? error.message + : "평가 확정 중 오류가 발생했습니다" + ) + } +} + +/** + * 평가 확정을 취소합니다 (필요시 추가) + */ +export async function unfinalizeEvaluations(evaluationIds: number[]) { + try { + const currentUser = await getCurrentUser() + if (!currentUser) { + throw new Error("인증이 필요합니다") + } + + await db.transaction(async (tx) => { + for (const evaluationId of evaluationIds) { + // 1. 평가 상태가 FINALIZED인지 확인 + const existingEvaluation = await tx + .select({ + id: periodicEvaluations.id, + status: periodicEvaluations.status, + }) + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, evaluationId)) + .limit(1) + + if (existingEvaluation.length === 0) { + throw new Error(`평가를 찾을 수 없습니다: ID ${evaluationId}`) + } + + if (existingEvaluation[0].status !== "FINALIZED") { + throw new Error( + `평가 ${evaluationId}는 확정 상태가 아닙니다. 현재 상태: ${existingEvaluation[0].status}` + ) + } + + // 2. 확정 해제 - 검토 완료 상태로 되돌림 + await tx + .update(periodicEvaluations) + .set({ + finalScore: null, + finalGrade: null, + status: "REVIEW_COMPLETED", + finalizedAt: null, + finalizedBy: null, + updatedAt: new Date(), + }) + .where(eq(periodicEvaluations.id, evaluationId)) } + }) + + revalidatePath("/evcp/evaluation") + revalidatePath("/procurement/evaluation") + + return { + success: true, + message: `${evaluationIds.length}건의 평가 확정이 취소되었습니다`, } + } catch (error) { + console.error("Error unfinalizing evaluations:", error) + throw new Error( + error instanceof Error + ? error.message + : "평가 확정 취소 중 오류가 발생했습니다" + ) } +} + + +// 평가 상세 정보 타입 +export interface EvaluationDetailData { + // 리뷰어 정보 + reviewerEvaluationId: number + reviewerName: string + reviewerEmail: string + departmentCode: string + departmentName: string + isCompleted: boolean + completedAt: Date | null + reviewerComment: string | null - // 기존 요청 상태 확인 함수 추가 - export async function checkExistingSubmissions(periodicEvaluationIds: number[]) { - try { - const existingSubmissions = await db.query.evaluationSubmissions.findMany({ - where: (submissions) => { - // periodicEvaluationIds 배열에 포함된 ID들을 확인 - return periodicEvaluationIds.length === 1 - ? eq(submissions.periodicEvaluationId, periodicEvaluationIds[0]) - : periodicEvaluationIds.length > 1 - ? or(...periodicEvaluationIds.map(id => eq(submissions.periodicEvaluationId, id))) - : eq(submissions.id, -1) // 빈 배열인 경우 결과 없음 - }, - columns: { - id: true, - periodicEvaluationId: true, - companyId: true, - createdAt: true, - reviewComments: true - } + // 평가 항목별 상세 + evaluationItems: { + // 평가 기준 정보 + criteriaId: number + category: string + category2: string + item: string + classification: string + range: string | null + remarks: string | null + scoreType: string + + // 선택된 옵션 정보 (fixed 타입인 경우) + selectedDetailId: number | null + selectedDetail: string | null + + // 점수 및 의견 + score: number | null + comment: string | null + }[] +} + + +/** + * 특정 정기평가의 상세 정보를 조회합니다 + */ +export async function getEvaluationDetails(periodicEvaluationId: number): Promise<{ + evaluationInfo: { + id: number + vendorName: string + vendorCode: string + evaluationYear: number + division: string + status: string + } + reviewerDetails: EvaluationDetailData[] +}> { + try { + // 1. 평가 기본 정보 조회 + const evaluationInfo = await db + .select({ + id: periodicEvaluations.id, + vendorName: evaluationTargets.vendorName, + vendorCode: evaluationTargets.vendorCode, + evaluationYear: evaluationTargets.evaluationYear, + division: evaluationTargets.division, + status: periodicEvaluations.status, }) - - return existingSubmissions - } catch (error) { - console.error("Error checking existing submissions:", error) - return [] + .from(periodicEvaluations) + .leftJoin(evaluationTargets, eq(periodicEvaluations.evaluationTargetId, evaluationTargets.id)) + .where(eq(periodicEvaluations.id, periodicEvaluationId)) + .limit(1) + + if (evaluationInfo.length === 0) { + throw new Error("평가를 찾을 수 없습니다") } - } \ No newline at end of file + + // 2. 리뷰어별 평가 상세 정보 조회 + const reviewerDetailsRaw = await db + .select({ + // 리뷰어 평가 기본 정보 + reviewerEvaluationId: reviewerEvaluations.id, + reviewerName: users.name, + reviewerEmail: users.email, + departmentCode: evaluationTargetReviewers.departmentCode, + isCompleted: reviewerEvaluations.isCompleted, + completedAt: reviewerEvaluations.completedAt, + reviewerComment: reviewerEvaluations.reviewerComment, + + // 평가 항목 상세 + detailId: reviewerEvaluationDetails.id, + criteriaId: regEvalCriteria.id, + category: regEvalCriteria.category, + category2: regEvalCriteria.category2, + item: regEvalCriteria.item, + classification: regEvalCriteria.classification, + range: regEvalCriteria.range, + remarks: regEvalCriteria.remarks, + scoreType: regEvalCriteria.scoreType, + + // 선택된 옵션 정보 + selectedDetailId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, + selectedDetail: regEvalCriteriaDetails.detail, + + // 점수 및 의견 + score: reviewerEvaluationDetails.score, + comment: reviewerEvaluationDetails.comment, + }) + .from(reviewerEvaluations) + .leftJoin(evaluationTargetReviewers, eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id)) + .leftJoin(users, eq(evaluationTargetReviewers.reviewerUserId, users.id)) + .leftJoin(reviewerEvaluationDetails, eq(reviewerEvaluations.id, reviewerEvaluationDetails.reviewerEvaluationId)) + .leftJoin(regEvalCriteriaDetails, eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id)) + .leftJoin(regEvalCriteria, eq(regEvalCriteriaDetails.criteriaId, regEvalCriteria.id)) + .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId)) + .orderBy(evaluationTargetReviewers.departmentCode, regEvalCriteria.category, regEvalCriteria.classification) + + // 3. 리뷰어별로 그룹화 + const reviewerDetailsMap = new Map() + + reviewerDetailsRaw.forEach(row => { + if (!reviewerDetailsMap.has(row.reviewerEvaluationId)) { + reviewerDetailsMap.set(row.reviewerEvaluationId, { + reviewerEvaluationId: row.reviewerEvaluationId, + reviewerName: row.reviewerName || "", + reviewerEmail: row.reviewerEmail || "", + departmentCode: row.departmentCode || "", + departmentName: DEPARTMENT_CODE_LABELS[row.departmentCode as keyof typeof DEPARTMENT_CODE_LABELS] || row.departmentCode || "", + isCompleted: row.isCompleted || false, + completedAt: row.completedAt, + reviewerComment: row.reviewerComment, + evaluationItems: [] + }) + } + + // 평가 항목이 있는 경우에만 추가 + if (row.criteriaId && row.detailId) { + const reviewer = reviewerDetailsMap.get(row.reviewerEvaluationId)! + reviewer.evaluationItems.push({ + criteriaId: row.criteriaId, + category: row.category || "", + category2: row.category2 || "", + item: row.item || "", + classification: row.classification || "", + range: row.range, + remarks: row.remarks, + scoreType: row.scoreType || "fixed", + selectedDetailId: row.selectedDetailId, + selectedDetail: row.selectedDetail, + score: row.score ? Number(row.score) : null, + comment: row.comment + }) + } + }) + + return { + evaluationInfo: evaluationInfo[0], + reviewerDetails: Array.from(reviewerDetailsMap.values()) + } + + } catch (error) { + console.error("Error fetching evaluation details:", error) + throw new Error( + error instanceof Error + ? error.message + : "평가 상세 정보 조회 중 오류가 발생했습니다" + ) + } +} \ No newline at end of file diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx index 10aa7704..e88c5764 100644 --- a/lib/evaluation/table/evaluation-columns.tsx +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -8,10 +8,11 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText } from "lucide-react"; +import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle } from "lucide-react"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { PeriodicEvaluationView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; +import { vendortypeMap } from "@/types/evaluation"; @@ -48,6 +49,63 @@ const getStatusLabel = (status: string) => { return statusMap[status] || status; }; +// 부서별 상태 배지 함수 +const getDepartmentStatusBadge = (status: string | null) => { + if (!status) return ( +
+ {/* */} + - +
+ ); + + switch (status) { + case "NOT_ASSIGNED": + return ( +
+ {/* */} + 미지정 +
+ ); + case "NOT_STARTED": + return ( +
+
+ + {/* 시작전 */} +
+ ); + case "IN_PROGRESS": + return ( +
+
+ {/* 진행중 */} +
+ ); + case "COMPLETED": + return ( +
+
+ {/* 완료 */} +
+ ); + default: + return ( +
+ {/* */} + - +
+ ); + } +}; +// 부서명 라벨 +const DEPARTMENT_LABELS = { + ORDER_EVAL: "발주", + PROCUREMENT_EVAL: "조달", + QUALITY_EVAL: "품질", + DESIGN_EVAL: "설계", + CS_EVAL: "CS" +} as const; + // 등급별 색상 const getGradeBadgeVariant = (grade: string | null) => { if (!grade) return "outline"; @@ -78,19 +136,15 @@ const getDivisionBadge = (division: string) => { // 자재구분 배지 const getMaterialTypeBadge = (materialType: string) => { - const typeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return {typeMap[materialType] || materialType}; + + return {vendortypeMap[materialType] || materialType}; }; // 내외자 배지 const getDomesticForeignBadge = (domesticForeign: string) => { return ( - {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + {domesticForeign === "DOMESTIC" ? "D" : "F"} ); }; @@ -237,70 +291,41 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): // 진행 현황 // ═══════════════════════════════════════════════════════════════ { - header: "평가자 진행 현황", + header: "부서별 평가 현황", columns: [ { - accessorKey: "status", - header: ({ column }) => , - cell: ({ row }) => { - const status = row.getValue("status"); - return ( - - {getStatusLabel(status)} - - ); - }, - size: 100, + accessorKey: "orderEvalStatus", + header: ({ column }) => , + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("orderEvalStatus")), + size: 60, }, - + { - id: "reviewProgress", - header: ({ column }) => , - cell: ({ row }) => { - const totalReviewers = row.original.totalReviewers || 0; - const completedReviewers = row.original.completedReviewers || 0; - - return getProgressBadge(completedReviewers, totalReviewers); - }, - size: 120, + accessorKey: "procurementEvalStatus", + header: ({ column }) => , + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("procurementEvalStatus")), + size: 70, }, - + { - accessorKey: "reviewCompletedAt", - header: ({ column }) => , - cell: ({ row }) => { - const completedAt = row.getValue("reviewCompletedAt"); - return completedAt ? ( - - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(completedAt))} - - ) : ( - - - ); - }, - size: 100, + accessorKey: "qualityEvalStatus", + header: ({ column }) => , + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("qualityEvalStatus")), + size: 70, }, - + { - accessorKey: "finalizedAt", - header: ({ column }) => , - cell: ({ row }) => { - const finalizedAt = row.getValue("finalizedAt"); - return finalizedAt ? ( - - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(finalizedAt))} - - ) : ( - - - ); - }, - size: 80, + accessorKey: "designEvalStatus", + header: ({ column }) => , + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("designEvalStatus")), + size: 70, + }, + + { + accessorKey: "csEvalStatus", + header: ({ column }) => , + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("csEvalStatus")), + size: 70, }, ] }, @@ -321,7 +346,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ); }, - size: 100, + size: 120, }, { @@ -519,7 +544,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): - ); }, - size: 80, + minSize: 100, }, ] @@ -528,38 +553,28 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): // ░░░ Actions ░░░ - // { - // id: "actions", - // enableHiding: false, - // size: 40, - // minSize: 40, - // cell: ({ row }) => { - // return ( - //
- // - - // - //
- // ); - // }, - // }, + { + id: "actions", + enableHiding: false, + size: 40, + minSize: 40, + cell: ({ row }) => { + return ( +
+ + +
+ ); + }, + }, ]; } \ No newline at end of file diff --git a/lib/evaluation/table/evaluation-details-dialog.tsx b/lib/evaluation/table/evaluation-details-dialog.tsx new file mode 100644 index 00000000..df4ef016 --- /dev/null +++ b/lib/evaluation/table/evaluation-details-dialog.tsx @@ -0,0 +1,366 @@ +"use client" + +import * as React from "react" +import { + Eye, + Building2, + User, + Calendar, + CheckCircle2, + Clock, + MessageSquare, + Award, + FileText +} from "lucide-react" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { PeriodicEvaluationView } from "@/db/schema" +import { getEvaluationDetails, type EvaluationDetailData } from "../service" + +interface EvaluationDetailsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluation: PeriodicEvaluationView | null +} + +// 카테고리별 색상 매핑 +const getCategoryBadgeVariant = (category: string) => { + switch (category) { + case "quality": + return "default" + case "delivery": + return "secondary" + case "price": + return "outline" + case "cooperation": + return "destructive" + default: + return "outline" + } +} + +// 카테고리명 매핑 +const CATEGORY_LABELS = { + "customer-service": "CS", + administrator: "관리자", + procurement: "구매", + design: "설계", + sourcing: "조달", + quality: "품질" +} as const + +const CATEGORY_LABELS2 = { + bonus: "가점항목", + delivery: "납기", + management: "경영현황", + penalty: "감점항목", + procurement: "구매", + quality: "품질" + } as const + +export function EvaluationDetailsDialog({ + open, + onOpenChange, + evaluation, +}: EvaluationDetailsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [evaluationDetails, setEvaluationDetails] = React.useState<{ + evaluationInfo: any + reviewerDetails: EvaluationDetailData[] + } | null>(null) + + // 평가 상세 정보 로드 + React.useEffect(() => { + if (open && evaluation?.id) { + const loadEvaluationDetails = async () => { + try { + setIsLoading(true) + const details = await getEvaluationDetails(evaluation.id) + setEvaluationDetails(details) + } catch (error) { + console.error("Failed to load evaluation details:", error) + } finally { + setIsLoading(false) + } + } + + loadEvaluationDetails() + } + }, [open, evaluation?.id]) + + // 다이얼로그 닫을 때 데이터 리셋 + React.useEffect(() => { + if (!open) { + setEvaluationDetails(null) + } + }, [open]) + + if (!evaluation) return null + + return ( + + + + + + 평가 상세 + + + {/* 평가 기본 정보 */} + + + + + 평가 정보 + + + +
+ {/* 협력업체 */} +
+ 협력업체: + {evaluation.vendorName} + ({evaluation.vendorCode}) +
+ + {/* 평가년도 */} +
+ 년도: + {evaluation.evaluationYear}년 +
+ + {/* 구분 */} +
+ 구분: + + {evaluation.division === "PLANT" ? "해양" : "조선"} + +
+ + {/* 진행상태 */} +
+ 상태: + {evaluation.status} +
+ + {/* 평가점수/등급 */} +
+ 평가점수/등급: + {evaluation.evaluationScore ? ( +
+ + {Number(evaluation.evaluationScore).toFixed(1)}점 + + {evaluation.evaluationGrade && ( + + {evaluation.evaluationGrade} + + )} +
+ ) : ( + - + )} +
+ + {/* 확정점수/등급 */} +
+ 확정점수/등급: + {evaluation.finalScore ? ( +
+ + {Number(evaluation.finalScore).toFixed(1)}점 + + {evaluation.finalGrade && ( + + {evaluation.finalGrade} + + )} +
+ ) : ( + 미확정 + )} +
+
+
+
+
+ + {isLoading ? ( +
+ + + + + + + + +
+ ) : evaluationDetails ? ( +
+ {/* 통합 평가 테이블 */} + + + + + 평가 상세 내역 + + + + {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( + + + + 담당자 + {/* 상태 */} + 평가부문 + 항목 + 구분 + 범위 + 선택옵션 + 점수 + 의견 + + + + {evaluationDetails.reviewerDetails.map((reviewer) => + reviewer.evaluationItems.map((item, index) => ( + + +
+
{reviewer.departmentName}
+
+ {reviewer.reviewerName} +
+
+
+ {/* + {reviewer.isCompleted ? ( + + + 완료 + + ) : ( + + + 진행중 + + )} + */} + + + {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} + + + + {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} + + + {item.classification} + + + {item.range || "-"} + + + {item.scoreType === "variable" ? ( + 직접 입력 + ) : ( + item.selectedDetail || "-" + )} + + + {item.score !== null ? ( + + {item.score.toFixed(1)} + + ) : ( + - + )} + + + {item.comment || ( + 의견 없음 + )} + +
+ )) + )} +
+
+ ) : ( +
+ +
평가 항목이 없습니다
+
+ )} +
+
+ + {/* 리뷰어별 종합 의견 (있는 경우만) */} + {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( + + + + + 종합 의견 + + + + {evaluationDetails.reviewerDetails + .filter(reviewer => reviewer.reviewerComment) + .map((reviewer) => ( +
+
+ {reviewer.departmentName} + {reviewer.reviewerName} +
+
+ {reviewer.reviewerComment} +
+
+ ))} +
+
+ )} + + {evaluationDetails.reviewerDetails.length === 0 && ( + + +
+ +
배정된 리뷰어가 없습니다
+
+
+
+ )} +
+ ) : ( + + +
+ 평가 상세 정보를 불러올 수 없습니다 +
+
+
+ )} + +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index 9e32debb..cecaeeaa 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -25,6 +25,7 @@ import { getPeriodicEvaluationsColumns } from "./evaluation-columns" import { PeriodicEvaluationView } from "@/db/schema" import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service" import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions" +import { EvaluationDetailsDialog } from "./evaluation-details-dialog" interface PeriodicEvaluationsTableProps { promises: Promise<[Awaited>]> @@ -456,7 +457,15 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } - {/* TODO: 수정/상세보기 모달 구현 */} + { + if (!open) { + setRowAction(null) + } + }} + evaluation={rowAction?.row.original || null} + />
diff --git a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx index 30ff9535..fc07aea1 100644 --- a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx +++ b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx @@ -14,11 +14,42 @@ import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { FileText, Users, Calendar, Send } from "lucide-react" +import { FileText, Users, Calendar, Send, Mail, Building } from "lucide-react" import { toast } from "sonner" import { PeriodicEvaluationView } from "@/db/schema" -import { checkExistingSubmissions, requestDocumentsFromVendors } from "../service" +import { + checkExistingSubmissions, + requestDocumentsFromVendors, + getReviewersForEvaluations, + createReviewerEvaluationsRequest +} from "../service" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" +// ================================================================ +// 부서 코드 매핑 +// ================================================================ + + +const getDepartmentLabel = (code: string): string => { + return DEPARTMENT_CODE_LABELS[code as keyof typeof DEPARTMENT_CODE_LABELS] || code +} + +// ================================================================ +// 타입 정의 +// ================================================================ +interface ReviewerInfo { + id: number + name: string + email: string + deptName: string | null + departmentCode: string + evaluationTargetId: number + evaluationTargetReviewerId: number +} + +interface EvaluationWithReviewers extends PeriodicEvaluationView { + reviewers: ReviewerInfo[] +} // ================================================================ // 2. 협력업체 자료 요청 다이얼로그 @@ -259,10 +290,8 @@ export function RequestDocumentsDialog({ ) } - - // ================================================================ -// 3. 평가자 평가 요청 다이얼로그 +// 3. 평가자 평가 요청 다이얼로그 (업데이트됨) // ================================================================ interface RequestEvaluationDialogProps { open: boolean @@ -278,10 +307,61 @@ export function RequestEvaluationDialog({ onSuccess, }: RequestEvaluationDialogProps) { const [isLoading, setIsLoading] = React.useState(false) + const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false) const [message, setMessage] = React.useState("") + const [evaluationsWithReviewers, setEvaluationsWithReviewers] = React.useState([]) // 제출완료 상태인 평가들만 필터링 - const submittedEvaluations = evaluations.filter(e => e.status === "SUBMITTED") + const submittedEvaluations = evaluations.filter(e => + e.status === "SUBMITTED" || e.status === "PENDING_SUBMISSION" + ) + + // 리뷰어 정보 로딩 + React.useEffect(() => { + if (!open || submittedEvaluations.length === 0) { + setEvaluationsWithReviewers([]) + return + } + + const loadReviewers = async () => { + setIsLoadingReviewers(true) + try { + const evaluationTargetIds = submittedEvaluations + .map(e => e.evaluationTargetId) + .filter(id => id !== null) + + if (evaluationTargetIds.length === 0) { + setEvaluationsWithReviewers([]) + return + } + + const reviewersData = await getReviewersForEvaluations(evaluationTargetIds) + + // 평가별로 리뷰어 그룹핑 + const evaluationsWithReviewersData = submittedEvaluations.map(evaluation => ({ + ...evaluation, + reviewers: reviewersData.filter(reviewer => + reviewer.evaluationTargetId === evaluation.evaluationTargetId + ) + })) + + setEvaluationsWithReviewers(evaluationsWithReviewersData) + } catch (error) { + console.error('Error loading reviewers:', error) + toast.error("평가자 정보를 불러오는데 실패했습니다.") + setEvaluationsWithReviewers([]) + } finally { + setIsLoadingReviewers(false) + } + } + + loadReviewers() + }, [open, submittedEvaluations.length]) + + // 총 리뷰어 수 계산 + const totalReviewers = evaluationsWithReviewers.reduce((sum, evaluation) => + sum + evaluation.reviewers.length, 0 + ) const handleSubmit = async () => { if (!message.trim()) { @@ -289,13 +369,34 @@ export function RequestEvaluationDialog({ return } + if (evaluationsWithReviewers.length === 0) { + toast.error("평가 요청할 대상이 없습니다.") + return + } + setIsLoading(true) try { - // TODO: 평가자들에게 평가 요청 API 호출 - toast.success(`${submittedEvaluations.length}개 평가에 대한 평가 요청이 발송되었습니다.`) - onSuccess() - onOpenChange(false) - setMessage("") + // 리뷰어 평가 레코드 생성 데이터 준비 + const reviewerEvaluationsData = evaluationsWithReviewers.flatMap(evaluation => + evaluation.reviewers.map(reviewer => ({ + periodicEvaluationId: evaluation.id, + evaluationTargetId: evaluation.evaluationTargetId, // 추가됨 + evaluationTargetReviewerId: reviewer.evaluationTargetReviewerId, + message: message.trim() + })) + ) + + // 서버 액션 호출 + const result = await createReviewerEvaluationsRequest(reviewerEvaluationsData) + + if (result.success) { + toast.success(result.message) + onSuccess() + onOpenChange(false) + setMessage("") + } else { + toast.error(result.message) + } } catch (error) { console.error('Error requesting evaluation:', error) toast.error("평가 요청 발송 중 오류가 발생했습니다.") @@ -306,7 +407,7 @@ export function RequestEvaluationDialog({ return ( - + @@ -318,28 +419,84 @@ export function RequestEvaluationDialog({
- {/* 대상 평가 목록 */} - - - - 평가 대상 ({submittedEvaluations.length}개 평가) - - - - {submittedEvaluations.map((evaluation) => ( -
- {evaluation.vendorName} -
- {evaluation.evaluationPeriod} - 제출완료 + {isLoadingReviewers ? ( +
+
평가자 정보를 불러오고 있습니다...
+
+ ) : ( + <> + {/* 평가별 리뷰어 목록 */} + {evaluationsWithReviewers.length > 0 ? ( +
+
+ 총 {evaluationsWithReviewers.length}개 평가, {totalReviewers}명의 평가자
+ + {evaluationsWithReviewers.map((evaluation) => ( + + + + {evaluation.vendorName} +
+ {evaluation.vendorCode} + + {evaluation.submissionDate ? "자료 제출완료" : "자료 미제출"} + +
+
+
+ + {evaluation.reviewers.length > 0 ? ( +
+
+ 평가자 {evaluation.reviewers.length}명 +
+
+ {evaluation.reviewers.map((reviewer) => ( +
+
+
{reviewer.name}
+
+ + {reviewer.email} +
+ {reviewer.deptName && ( +
+ + {reviewer.deptName} +
+ )} +
+ + {getDepartmentLabel(reviewer.departmentCode)} + +
+ ))} +
+
+ ) : ( +
+ 지정된 평가자가 없습니다. +
+ )} +
+
+ ))}
- ))} - - + ) : ( + + +
+ 평가 요청할 대상이 없습니다. +
+
+
+ )} + + )} {/* 요청 메시지 */}
@@ -350,6 +507,7 @@ export function RequestEvaluationDialog({ value={message} onChange={(e) => setMessage(e.target.value)} rows={4} + disabled={isLoadingReviewers} />
@@ -358,13 +516,16 @@ export function RequestEvaluationDialog({ - diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx new file mode 100644 index 00000000..7d6ca45d --- /dev/null +++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx @@ -0,0 +1,305 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import * as z from "zod" +import { toast } from "sonner" +import { CheckCircle2, AlertCircle, Building2 } from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { PeriodicEvaluationView } from "@/db/schema" +import { finalizeEvaluations } from "../service" + +// 등급 옵션 +const GRADE_OPTIONS = [ + { value: "S", label: "S등급 (90점 이상)" }, + { value: "A", label: "A등급 (80-89점)" }, + { value: "B", label: "B등급 (70-79점)" }, + { value: "C", label: "C등급 (60-69점)" }, + { value: "D", label: "D등급 (60점 미만)" }, +] as const + +// 점수에 따른 등급 계산 +const calculateGrade = (score: number): string => { + if (score >= 90) return "S" + if (score >= 80) return "A" + if (score >= 70) return "B" + if (score >= 60) return "C" + return "D" +} + +// 개별 평가 스키마 +const evaluationItemSchema = z.object({ + id: z.number(), + vendorName: z.string(), + vendorCode: z.string(), + evaluationScore: z.number().nullable(), + finalScore: z.number() + .min(0, "점수는 0 이상이어야 합니다"), + // .max(100, "점수는 100 이하여야 합니다"), + finalGrade: z.enum(["S", "A", "B", "C", "D"]), +}) + +// 전체 폼 스키마 +const finalizeEvaluationSchema = z.object({ + evaluations: z.array(evaluationItemSchema).min(1, "확정할 평가가 없습니다"), +}) + +type FinalizeEvaluationFormData = z.infer + +interface FinalizeEvaluationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluations: PeriodicEvaluationView[] + onSuccess?: () => void +} + +export function FinalizeEvaluationDialog({ + open, + onOpenChange, + evaluations, + onSuccess, +}: FinalizeEvaluationDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm({ + resolver: zodResolver(finalizeEvaluationSchema), + defaultValues: { + evaluations: [], + }, + }) + + const { fields, update } = useFieldArray({ + control: form.control, + name: "evaluations", + }) + + // evaluations가 변경될 때 폼 초기화 + React.useEffect(() => { + if (evaluations.length > 0) { + const formData = evaluations.map(evaluation => ({ + id: evaluation.id, + vendorName: evaluation.vendorName || "", + vendorCode: evaluation.vendorCode || "", + evaluationScore: evaluation.evaluationScore || null, + finalScore: Number(evaluation.evaluationScore || 0), + finalGrade: calculateGrade(Number(evaluation.evaluationScore || 0)), + })) + + form.reset({ evaluations: formData }) + } + }, [evaluations, form]) + + // 점수 변경 시 등급 자동 계산 + const handleScoreChange = (index: number, score: number) => { + const currentEvaluation = form.getValues(`evaluations.${index}`) + const newGrade = calculateGrade(score) + + update(index, { + ...currentEvaluation, + finalScore: score, + finalGrade: newGrade, + }) + } + + // 폼 제출 + const onSubmit = async (data: FinalizeEvaluationFormData) => { + try { + setIsLoading(true) + + const finalizeData = data.evaluations.map(evaluation => ({ + id: evaluation.id, + finalScore: evaluation.finalScore, + finalGrade: evaluation.finalGrade, + })) + + await finalizeEvaluations(finalizeData) + + toast.success("평가가 확정되었습니다", { + description: `${data.evaluations.length}건의 평가가 최종 확정되었습니다.`, + }) + + onSuccess?.() + onOpenChange(false) + } catch (error) { + console.error("Failed to finalize evaluations:", error) + toast.error("평가 확정 실패", { + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }) + } finally { + setIsLoading(false) + } + } + + return ( + + + + + + 평가 확정 + + + 검토가 완료된 평가의 최종 점수와 등급을 확정합니다. + 확정 후에는 수정이 제한됩니다. + + + + + + + 확정할 평가: {evaluations.length}건 +
+ 평가 점수는 리뷰어들의 평가를 바탕으로 계산된 값을 기본으로 하며, 필요시 조정 가능합니다. +
+
+ +
+ +
+ + + + 협력업체 + 평가점수 + 최종점수 + 최종등급 + + + + {fields.map((field, index) => ( + + +
+
+ {form.watch(`evaluations.${index}.vendorName`)} +
+
+ {form.watch(`evaluations.${index}.vendorCode`)} +
+
+
+ + +
+ {form.watch(`evaluations.${index}.evaluationScore`) !== null ? ( + + {Number(form.watch(`evaluations.${index}.evaluationScore`)).toFixed(1)}점 + + ) : ( + - + )} +
+
+ + + ( + + + { + const value = parseFloat(e.target.value) + field.onChange(value) + if (!isNaN(value)) { + handleScoreChange(index, value) + } + }} + className="text-center font-mono" + /> + + + + )} + /> + + + + ( + + + + + + + )} + /> + +
+ ))} +
+
+
+ + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx index 2d2bebc1..bb63a1fd 100644 --- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx +++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { type Table } from "@tanstack/react-table" import { @@ -9,7 +7,8 @@ import { Download, RefreshCw, FileText, - MessageSquare + MessageSquare, + CheckCircle2 } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" @@ -28,6 +27,7 @@ import { } from "./periodic-evaluation-action-dialogs" import { PeriodicEvaluationView } from "@/db/schema" import { exportTableToExcel } from "@/lib/export" +import { FinalizeEvaluationDialog } from "./periodic-evaluation-finalize-dialogs" interface PeriodicEvaluationsTableToolbarActionsProps { table: Table @@ -42,20 +42,66 @@ export function PeriodicEvaluationsTableToolbarActions({ const [createEvaluationDialogOpen, setCreateEvaluationDialogOpen] = React.useState(false) const [requestDocumentsDialogOpen, setRequestDocumentsDialogOpen] = React.useState(false) const [requestEvaluationDialogOpen, setRequestEvaluationDialogOpen] = React.useState(false) + const [finalizeEvaluationDialogOpen, setFinalizeEvaluationDialogOpen] = React.useState(false) const router = useRouter() // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows const hasSelection = selectedRows.length > 0 - const selectedEvaluations = selectedRows.map(row => row.original) - // 선택된 항목들의 상태 분석 + // ✅ selectedEvaluations를 useMemo로 안정화 (VendorsTable 방식과 동일) + const selectedEvaluations = React.useMemo(() => { + return selectedRows.map(row => row.original) + }, [selectedRows]) + + // ✅ 각 상태별 평가들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) + const pendingSubmissionEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "PENDING_SUBMISSION"); + }, [table.getFilteredSelectedRowModel().rows]); + + const submittedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "SUBMITTED" || e.status === "PENDING_SUBMISSION"); + }, [table.getFilteredSelectedRowModel().rows]); + + const inReviewEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "IN_REVIEW"); + }, [table.getFilteredSelectedRowModel().rows]); + + const reviewCompletedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "REVIEW_COMPLETED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const finalizedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "FINALIZED"); + }, [table.getFilteredSelectedRowModel().rows]); + + // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 const selectedStats = React.useMemo(() => { - const pendingSubmission = selectedEvaluations.filter(e => e.status === "PENDING_SUBMISSION").length - const submitted = selectedEvaluations.filter(e => e.status === "SUBMITTED").length - const inReview = selectedEvaluations.filter(e => e.status === "IN_REVIEW").length - const reviewCompleted = selectedEvaluations.filter(e => e.status === "REVIEW_COMPLETED").length - const finalized = selectedEvaluations.filter(e => e.status === "FINALIZED").length + const pendingSubmission = pendingSubmissionEvaluations.length + const submitted = submittedEvaluations.length + const inReview = inReviewEvaluations.length + const reviewCompleted = reviewCompletedEvaluations.length + const finalized = finalizedEvaluations.length // 협력업체에게 자료 요청 가능: PENDING_SUBMISSION 상태 const canRequestDocuments = pendingSubmission > 0 @@ -63,6 +109,9 @@ export function PeriodicEvaluationsTableToolbarActions({ // 평가자에게 평가 요청 가능: SUBMITTED 상태 (제출됐지만 아직 평가 시작 안됨) const canRequestEvaluation = submitted > 0 + // 평가 확정 가능: REVIEW_COMPLETED 상태 + const canFinalizeEvaluation = reviewCompleted > 0 + return { pendingSubmission, submitted, @@ -71,42 +120,37 @@ export function PeriodicEvaluationsTableToolbarActions({ finalized, canRequestDocuments, canRequestEvaluation, + canFinalizeEvaluation, total: selectedEvaluations.length } - }, [selectedEvaluations]) - - // ---------------------------------------------------------------- - // 신규 정기평가 생성 (자동) - // ---------------------------------------------------------------- - const handleAutoGenerate = async () => { - setIsLoading(true) - try { - // TODO: 평가대상에서 자동 생성 API 호출 - toast.success("정기평가가 자동으로 생성되었습니다.") - router.refresh() - } catch (error) { - console.error('Error auto generating periodic evaluations:', error) - toast.error("자동 생성 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - // ---------------------------------------------------------------- - // 신규 정기평가 생성 (수동) - // ---------------------------------------------------------------- - const handleManualCreate = () => { - setCreateEvaluationDialogOpen(true) - } - + }, [ + pendingSubmissionEvaluations.length, + submittedEvaluations.length, + inReviewEvaluations.length, + reviewCompletedEvaluations.length, + finalizedEvaluations.length, + selectedEvaluations.length + ]) + + // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = () => { + const handleActionSuccess = React.useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - } + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "periodic-evaluations", + excludeColumns: ["select", "actions"], + }) + }, [table]) return ( <> @@ -117,12 +161,7 @@ export function PeriodicEvaluationsTableToolbarActions({ )} - {/* 알림 발송 버튼 (선택사항) */} - + {/* 평가 확정 버튼 */} + {selectedStats.canFinalizeEvaluation && ( + + )}
)}
- {/* 협력업체 자료 요청 다이얼로그 */} - {/* 선택 정보 표시 (디버깅용 - 필요시 주석 해제) */} - {/* {hasSelection && ( -
- 선택된 {selectedRows.length}개 항목: - 제출대기 {selectedStats.pendingSubmission}개, - 제출완료 {selectedStats.submitted}개, - 검토중 {selectedStats.inReview}개, - 검토완료 {selectedStats.reviewCompleted}개, - 최종확정 {selectedStats.finalized}개 -
- )} */} + {/* 평가 확정 다이얼로그 */} + ) -} \ No newline at end of file +} -- cgit v1.2.3