summaryrefslogtreecommitdiff
path: root/lib/evaluation
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
commit90f79a7a691943a496f67f01c1e493256070e4de (patch)
tree37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation
parentfbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff)
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation')
-rw-r--r--lib/evaluation/service.ts1348
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx213
-rw-r--r--lib/evaluation/table/evaluation-details-dialog.tsx366
-rw-r--r--lib/evaluation/table/evaluation-table.tsx11
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx231
-rw-r--r--lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx305
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx179
7 files changed, 2100 insertions, 553 deletions
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<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: periodicEvaluationsView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: periodicEvaluationsView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- // 벤더 정보로 검색
- 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<unknown>[] = [];
-
- 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<unknown> | 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<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: periodicEvaluationsView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ // 벤더 정보로 검색
+ 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<unknown>[] = [];
+
+ 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<PeriodicEvaluationsStats> {
- 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<PeriodicEvaluationsStats> {
+ 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<string, number>)
+
+ // 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<string, number>)
-
- // 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<number>`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'),
+ completedReviewers: sql<number>`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'),
+ pendingReviewers: sql<number>`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<ReviewerInfo[]> {
+ 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<number>`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'),
- completedReviewers: sql<number>`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'),
- pendingReviewers: sql<number>`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<Array<{
+ periodicEvaluationId: number
+ totalReviewers: number
+ completedReviewers: number
+ completionRate: number
+}>> {
+ 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<number, EvaluationDetailData>()
+
+ 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 (
+ <div className="flex items-center gap-1">
+ {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */}
+ <span className="text-xs text-gray-500">-</span>
+ </div>
+ );
+
+ switch (status) {
+ case "NOT_ASSIGNED":
+ return (
+ <div className="flex items-center gap-1">
+ {/* <Circle className="w-4 h-4 fill-gray-400 text-gray-400" /> */}
+ <span className="text-xs text-gray-600">미지정</span>
+ </div>
+ );
+ case "NOT_STARTED":
+ return (
+ <div className="flex items-center gap-1">
+ <div className="w-4 h-4 rounded-full bg-red-500 shadow-sm" />
+
+ {/* <span className="text-xs text-red-600">시작전</span> */}
+ </div>
+ );
+ case "IN_PROGRESS":
+ return (
+ <div className="flex items-center gap-1">
+ <div className="w-4 h-4 rounded-full bg-yellow-500 shadow-sm" />
+ {/* <span className="text-xs text-yellow-600">진행중</span> */}
+ </div>
+ );
+ case "COMPLETED":
+ return (
+ <div className="flex items-center gap-1">
+ <div className="w-4 h-4 rounded-full bg-green-500 shadow-sm" />
+ {/* <span className="text-xs text-green-600">완료</span> */}
+ </div>
+ );
+ default:
+ return (
+ <div className="flex items-center gap-1">
+ {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */}
+ <span className="text-xs text-gray-500">-</span>
+ </div>
+ );
+ }
+};
+// 부서명 라벨
+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 <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>;
+
+ return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>;
};
// 내외자 배지
const getDomesticForeignBadge = (domesticForeign: string) => {
return (
<Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}>
- {domesticForeign === "DOMESTIC" ? "내자" : "외자"}
+ {domesticForeign === "DOMESTIC" ? "D" : "F"}
</Badge>
);
};
@@ -237,70 +291,41 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
// 진행 현황
// ═══════════════════════════════════════════════════════════════
{
- header: "평가자 진행 현황",
+ header: "부서별 평가 현황",
columns: [
{
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
- cell: ({ row }) => {
- const status = row.getValue<string>("status");
- return (
- <Badge variant={getStatusBadgeVariant(status)}>
- {getStatusLabel(status)}
- </Badge>
- );
- },
- size: 100,
+ accessorKey: "orderEvalStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발주" />,
+ cell: ({ row }) => getDepartmentStatusBadge(row.getValue("orderEvalStatus")),
+ size: 60,
},
-
+
{
- id: "reviewProgress",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
- cell: ({ row }) => {
- const totalReviewers = row.original.totalReviewers || 0;
- const completedReviewers = row.original.completedReviewers || 0;
-
- return getProgressBadge(completedReviewers, totalReviewers);
- },
- size: 120,
+ accessorKey: "procurementEvalStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조달" />,
+ cell: ({ row }) => getDepartmentStatusBadge(row.getValue("procurementEvalStatus")),
+ size: 70,
},
-
+
{
- accessorKey: "reviewCompletedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />,
- cell: ({ row }) => {
- const completedAt = row.getValue<Date>("reviewCompletedAt");
- return completedAt ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(completedAt))}
- </span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 100,
+ accessorKey: "qualityEvalStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질" />,
+ cell: ({ row }) => getDepartmentStatusBadge(row.getValue("qualityEvalStatus")),
+ size: 70,
},
-
+
{
- accessorKey: "finalizedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
- cell: ({ row }) => {
- const finalizedAt = row.getValue<Date>("finalizedAt");
- return finalizedAt ? (
- <span className="text-sm font-medium">
- {new Intl.DateTimeFormat("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(finalizedAt))}
- </span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 80,
+ accessorKey: "designEvalStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계" />,
+ cell: ({ row }) => getDepartmentStatusBadge(row.getValue("designEvalStatus")),
+ size: 70,
+ },
+
+ {
+ accessorKey: "csEvalStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="CS" />,
+ cell: ({ row }) => getDepartmentStatusBadge(row.getValue("csEvalStatus")),
+ size: 70,
},
]
},
@@ -321,7 +346,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
</Badge>
);
},
- size: 100,
+ size: 120,
},
{
@@ -519,7 +544,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
<span className="text-muted-foreground">-</span>
);
},
- 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 (
- // <div className="flex items-center gap-1">
- // <Button
- // variant="ghost"
- // size="icon"
- // className="size-8"
- // onClick={() => setRowAction({ row, type: "view" })}
- // aria-label="상세보기"
- // title="상세보기"
- // >
- // <Eye className="size-4" />
- // </Button>
-
- // <Button
- // variant="ghost"
- // size="icon"
- // className="size-8"
- // onClick={() => setRowAction({ row, type: "update" })}
- // aria-label="수정"
- // title="수정"
- // >
- // <Pencil className="size-4" />
- // </Button>
- // </div>
- // );
- // },
- // },
+ {
+ id: "actions",
+ enableHiding: false,
+ size: 40,
+ minSize: 40,
+ cell: ({ row }) => {
+ return (
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ onClick={() => setRowAction({ row, type: "view" })}
+ aria-label="상세보기"
+ title="상세보기"
+ >
+ <Eye className="size-4" />
+ </Button>
+
+ </div>
+ );
+ },
+ },
];
} \ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader className="space-y-4">
+ <DialogTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-blue-600" />
+ 평가 상세
+ </DialogTitle>
+
+ {/* 평가 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <Building2 className="h-5 w-5" />
+ 평가 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex flex-wrap items-center gap-6 text-sm">
+ {/* 협력업체 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">협력업체:</span>
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <span className="text-muted-foreground">({evaluation.vendorCode})</span>
+ </div>
+
+ {/* 평가년도 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">년도:</span>
+ <span className="font-medium">{evaluation.evaluationYear}년</span>
+ </div>
+
+ {/* 구분 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">구분:</span>
+ <Badge variant="outline" className="text-xs">
+ {evaluation.division === "PLANT" ? "해양" : "조선"}
+ </Badge>
+ </div>
+
+ {/* 진행상태 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">상태:</span>
+ <Badge variant="secondary" className="text-xs">{evaluation.status}</Badge>
+ </div>
+
+ {/* 평가점수/등급 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">평가점수/등급:</span>
+ {evaluation.evaluationScore ? (
+ <div className="flex items-center gap-1">
+ <span className="font-bold text-blue-600">
+ {Number(evaluation.evaluationScore).toFixed(1)}점
+ </span>
+ {evaluation.evaluationGrade && (
+ <Badge variant="default" className="text-xs h-5">
+ {evaluation.evaluationGrade}
+ </Badge>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+
+ {/* 확정점수/등급 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">확정점수/등급:</span>
+ {evaluation.finalScore ? (
+ <div className="flex items-center gap-1">
+ <span className="font-bold text-green-600">
+ {Number(evaluation.finalScore).toFixed(1)}점
+ </span>
+ {evaluation.finalGrade && (
+ <Badge variant="default" className="bg-green-600 text-xs h-5">
+ {evaluation.finalGrade}
+ </Badge>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">미확정</span>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="space-y-4">
+ <Card>
+ <CardHeader>
+ <Skeleton className="h-6 w-48" />
+ </CardHeader>
+ <CardContent>
+ <Skeleton className="h-64 w-full" />
+ </CardContent>
+ </Card>
+ </div>
+ ) : evaluationDetails ? (
+ <div className="space-y-6">
+ {/* 통합 평가 테이블 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 평가 상세 내역
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[120px]">담당자</TableHead>
+ {/* <TableHead className="w-[80px]">상태</TableHead> */}
+ <TableHead className="w-[100px]">평가부문</TableHead>
+ <TableHead className="w-[100px]">항목</TableHead>
+ <TableHead className="w-[150px]">구분</TableHead>
+ <TableHead className="w-[200px]">범위</TableHead>
+ <TableHead className="w-[200px]">선택옵션</TableHead>
+ <TableHead className="w-[80px]">점수</TableHead>
+ <TableHead className="min-w-[200px]">의견</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {evaluationDetails.reviewerDetails.map((reviewer) =>
+ reviewer.evaluationItems.map((item, index) => (
+ <TableRow key={`${reviewer.reviewerEvaluationId}-${item.criteriaId}-${index}`}>
+ <TableCell>
+ <div className="space-y-1">
+ <div className="font-medium text-sm">{reviewer.departmentName}</div>
+ <div className="text-xs text-muted-foreground">
+ {reviewer.reviewerName}
+ </div>
+ </div>
+ </TableCell>
+ {/* <TableCell>
+ {reviewer.isCompleted ? (
+ <Badge variant="default" className="flex items-center gap-1">
+ <CheckCircle2 className="h-3 w-3" />
+ 완료
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="flex items-center gap-1">
+ <Clock className="h-3 w-3" />
+ 진행중
+ </Badge>
+ )}
+ </TableCell> */}
+ <TableCell>
+ <Badge variant={getCategoryBadgeVariant(item.category)}>
+ {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item}
+ </TableCell>
+ <TableCell className="font-medium">
+ {item.classification}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.range || "-"}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.scoreType === "variable" ? (
+ <Badge variant="outline">직접 입력</Badge>
+ ) : (
+ item.selectedDetail || "-"
+ )}
+ </TableCell>
+ <TableCell>
+ {item.score !== null ? (
+ <Badge variant="default" className="font-mono">
+ {item.score.toFixed(1)}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.comment || (
+ <span className="text-muted-foreground">의견 없음</span>
+ )}
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ ) : (
+ <div className="text-center text-muted-foreground py-8">
+ <FileText className="h-8 w-8 mx-auto mb-2" />
+ <div>평가 항목이 없습니다</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 리뷰어별 종합 의견 (있는 경우만) */}
+ {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 종합 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {evaluationDetails.reviewerDetails
+ .filter(reviewer => reviewer.reviewerComment)
+ .map((reviewer) => (
+ <div key={reviewer.reviewerEvaluationId} className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">{reviewer.departmentName}</Badge>
+ <span className="text-sm font-medium">{reviewer.reviewerName}</span>
+ </div>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {reviewer.reviewerComment}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {evaluationDetails.reviewerDetails.length === 0 && (
+ <Card>
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <User className="h-8 w-8 mx-auto mb-2" />
+ <div>배정된 리뷰어가 없습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ 평가 상세 정보를 불러올 수 없습니다
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ <div className="flex justify-end pt-4">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<ReturnType<typeof getPeriodicEvaluations>>]>
@@ -456,7 +457,15 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
</DataTableAdvancedToolbar>
</DataTable>
- {/* TODO: 수정/상세보기 모달 구현 */}
+ <EvaluationDetailsDialog
+ open={rowAction?.type === "view"}
+ onOpenChange={(open) => {
+ if (!open) {
+ setRowAction(null)
+ }
+ }}
+ evaluation={rowAction?.row.original || null}
+ />
</div>
</div>
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<EvaluationWithReviewers[]>([])
// 제출완료 상태인 평가들만 필터링
- 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-2xl">
+ <DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="size-4" />
@@ -318,28 +419,84 @@ export function RequestEvaluationDialog({
</DialogHeader>
<div className="space-y-4">
- {/* 대상 평가 목록 */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-sm">
- 평가 대상 ({submittedEvaluations.length}개 평가)
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-2 max-h-32 overflow-y-auto">
- {submittedEvaluations.map((evaluation) => (
- <div
- key={evaluation.id}
- className="flex items-center justify-between text-sm"
- >
- <span className="font-medium">{evaluation.vendorName}</span>
- <div className="flex gap-2">
- <Badge variant="outline">{evaluation.evaluationPeriod}</Badge>
- <Badge variant="secondary">제출완료</Badge>
+ {isLoadingReviewers ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">평가자 정보를 불러오고 있습니다...</div>
+ </div>
+ ) : (
+ <>
+ {/* 평가별 리뷰어 목록 */}
+ {evaluationsWithReviewers.length > 0 ? (
+ <div className="space-y-4">
+ <div className="text-sm font-medium text-green-600">
+ 총 {evaluationsWithReviewers.length}개 평가, {totalReviewers}명의 평가자
</div>
+
+ {evaluationsWithReviewers.map((evaluation) => (
+ <Card key={evaluation.id}>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm flex items-center justify-between">
+ <span>{evaluation.vendorName}</span>
+ <div className="flex gap-2">
+ <Badge variant="outline">{evaluation.vendorCode}</Badge>
+ <Badge variant={evaluation.submissionDate ? "default" : "secondary"}>
+ {evaluation.submissionDate ? "자료 제출완료" : "자료 미제출"}
+ </Badge>
+ </div>
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {evaluation.reviewers.length > 0 ? (
+ <div className="space-y-2">
+ <div className="text-xs text-muted-foreground mb-2">
+ 평가자 {evaluation.reviewers.length}명
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+ {evaluation.reviewers.map((reviewer) => (
+ <div
+ key={reviewer.evaluationTargetReviewerId}
+ className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
+ >
+ <div className="flex-1">
+ <div className="font-medium text-sm">{reviewer.name}</div>
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <Mail className="size-3" />
+ {reviewer.email}
+ </div>
+ {reviewer.deptName && (
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <Building className="size-3" />
+ {reviewer.deptName}
+ </div>
+ )}
+ </div>
+ <Badge variant="outline" className="text-xs">
+ {getDepartmentLabel(reviewer.departmentCode)}
+ </Badge>
+ </div>
+ ))}
+ </div>
+ </div>
+ ) : (
+ <div className="text-sm text-muted-foreground text-center py-4">
+ 지정된 평가자가 없습니다.
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ ))}
</div>
- ))}
- </CardContent>
- </Card>
+ ) : (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="text-center text-sm text-muted-foreground">
+ 평가 요청할 대상이 없습니다.
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </>
+ )}
{/* 요청 메시지 */}
<div className="space-y-2">
@@ -350,6 +507,7 @@ export function RequestEvaluationDialog({
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={4}
+ disabled={isLoadingReviewers}
/>
</div>
</div>
@@ -358,13 +516,16 @@ export function RequestEvaluationDialog({
<Button
variant="outline"
onClick={() => onOpenChange(false)}
- disabled={isLoading}
+ disabled={isLoading || isLoadingReviewers}
>
취소
</Button>
- <Button onClick={handleSubmit} disabled={isLoading}>
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || isLoadingReviewers || totalReviewers === 0}
+ >
<Send className="size-4 mr-2" />
- {isLoading ? "발송 중..." : `${submittedEvaluations.length}개 평가 요청`}
+ {isLoading ? "발송 중..." : `${totalReviewers}명에게 평가 요청`}
</Button>
</DialogFooter>
</DialogContent>
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<typeof finalizeEvaluationSchema>
+
+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<FinalizeEvaluationFormData>({
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <CheckCircle2 className="h-5 w-5 text-purple-600" />
+ 평가 확정
+ </DialogTitle>
+ <DialogDescription>
+ 검토가 완료된 평가의 최종 점수와 등급을 확정합니다.
+ 확정 후에는 수정이 제한됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 확정할 평가: <strong>{evaluations.length}건</strong>
+ <br />
+ 평가 점수는 리뷰어들의 평가를 바탕으로 계산된 값을 기본으로 하며, 필요시 조정 가능합니다.
+ </AlertDescription>
+ </Alert>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[200px]">협력업체</TableHead>
+ <TableHead className="w-[100px]">평가점수</TableHead>
+ <TableHead className="w-[120px]">최종점수</TableHead>
+ <TableHead className="w-[120px]">최종등급</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {fields.map((field, index) => (
+ <TableRow key={field.id}>
+ <TableCell>
+ <div className="space-y-1">
+ <div className="font-medium">
+ {form.watch(`evaluations.${index}.vendorName`)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {form.watch(`evaluations.${index}.vendorCode`)}
+ </div>
+ </div>
+ </TableCell>
+
+ <TableCell>
+ <div className="text-center">
+ {form.watch(`evaluations.${index}.evaluationScore`) !== null ? (
+ <Badge variant="outline" className="font-mono">
+ {Number(form.watch(`evaluations.${index}.evaluationScore`)).toFixed(1)}점
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+ </TableCell>
+
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`evaluations.${index}.finalScore`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ max="100"
+ step="0.1"
+ {...field}
+ onChange={(e) => {
+ const value = parseFloat(e.target.value)
+ field.onChange(value)
+ if (!isNaN(value)) {
+ handleScoreChange(index, value)
+ }
+ }}
+ className="text-center font-mono"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`evaluations.${index}.finalGrade`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Select value={field.value} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {GRADE_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading}
+ className="bg-purple-600 hover:bg-purple-700"
+ >
+ {isLoading ? "확정 중..." : `평가 확정 (${fields.length}건)`}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<PeriodicEvaluationView>
@@ -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({
<Button
variant="outline"
size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "periodic-evaluations",
- excludeColumns: ["select", "actions"],
- })
- }
+ onClick={handleExport}
className="gap-2"
>
<Download className="size-4" aria-hidden="true" />
@@ -165,27 +204,25 @@ export function PeriodicEvaluationsTableToolbarActions({
</Button>
)}
- {/* 알림 발송 버튼 (선택사항) */}
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => {
- // TODO: 선택된 평가에 대한 알림 발송
- toast.info("알림이 발송되었습니다.")
- }}
- disabled={isLoading}
- >
- <MessageSquare className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- 알림 발송 ({selectedStats.total})
- </span>
- </Button>
+ {/* 평가 확정 버튼 */}
+ {selectedStats.canFinalizeEvaluation && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2 text-purple-600 border-purple-200 hover:bg-purple-50"
+ onClick={() => setFinalizeEvaluationDialogOpen(true)}
+ disabled={isLoading}
+ >
+ <CheckCircle2 className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 평가 확정 ({selectedStats.reviewCompleted})
+ </span>
+ </Button>
+ )}
</div>
)}
</div>
-
{/* 협력업체 자료 요청 다이얼로그 */}
<RequestDocumentsDialog
open={requestDocumentsDialogOpen}
@@ -202,17 +239,13 @@ export function PeriodicEvaluationsTableToolbarActions({
onSuccess={handleActionSuccess}
/>
- {/* 선택 정보 표시 (디버깅용 - 필요시 주석 해제) */}
- {/* {hasSelection && (
- <div className="text-xs text-muted-foreground mt-2">
- 선택된 {selectedRows.length}개 항목:
- 제출대기 {selectedStats.pendingSubmission}개,
- 제출완료 {selectedStats.submitted}개,
- 검토중 {selectedStats.inReview}개,
- 검토완료 {selectedStats.reviewCompleted}개,
- 최종확정 {selectedStats.finalized}개
- </div>
- )} */}
+ {/* 평가 확정 다이얼로그 */}
+ <FinalizeEvaluationDialog
+ open={finalizeEvaluationDialogOpen}
+ onOpenChange={setFinalizeEvaluationDialogOpen}
+ evaluations={reviewCompletedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
</>
)
-} \ No newline at end of file
+}