summaryrefslogtreecommitdiff
path: root/lib/evaluation
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation')
-rw-r--r--lib/evaluation/service.ts266
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx341
-rw-r--r--lib/evaluation/table/evaluation-table.tsx24
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx373
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx218
5 files changed, 1099 insertions, 123 deletions
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index 3cc4ca7d..19e41dff 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -1,5 +1,8 @@
+'use server'
+
import db from "@/db/db"
import {
+ evaluationSubmissions,
periodicEvaluationsView,
type PeriodicEvaluationView
} from "@/db/schema"
@@ -9,7 +12,7 @@ import {
count,
desc,
ilike,
- or,
+ or, sql , eq, avg,
type SQL
} from "drizzle-orm"
import { filterColumns } from "@/lib/filter-columns"
@@ -17,6 +20,7 @@ import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation
export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
try {
+
const offset = (input.page - 1) * input.perPage;
// ✅ getEvaluationTargets 방식과 동일한 필터링 처리
@@ -115,6 +119,8 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
.offset(offset);
const pageCount = Math.ceil(total / input.perPage);
+
+ console.log(periodicEvaluationsData,"periodicEvaluationsData")
return { data: periodicEvaluationsData, pageCount, total };
} catch (err) {
@@ -122,4 +128,262 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
// ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함)
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
+ }
+ }
+
+ 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
+ }
+ 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)
+ )
+ })
+
+ 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
+ }
+
+ } 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 []
+ }
} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 821e8182..10aa7704 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -144,14 +144,14 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
// ░░░ 평가기간 ░░░
- {
- accessorKey: "evaluationPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />,
- cell: ({ row }) => (
- <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge>
- ),
- size: 100,
- },
+ // {
+ // accessorKey: "evaluationPeriod",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />,
+ // cell: ({ row }) => (
+ // <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge>
+ // ),
+ // size: 100,
+ // },
// ░░░ 구분 ░░░
{
@@ -202,12 +202,113 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
]
},
+
+ {
+ accessorKey: "finalScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정점수" />,
+ cell: ({ row }) => {
+ const finalScore = row.getValue<number>("finalScore");
+ return finalScore ? (
+ <span className="font-bold text-green-600">{finalScore.toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 90,
+ },
+ {
+ accessorKey: "finalGrade",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정등급" />,
+ cell: ({ row }) => {
+ const finalGrade = row.getValue<string>("finalGrade");
+ return finalGrade ? (
+ <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600">
+ {finalGrade}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 90,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 진행 현황
+ // ═══════════════════════════════════════════════════════════════
+ {
+ 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,
+ },
+
+ {
+ 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: "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: "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,
+ },
+ ]
+ },
// ═══════════════════════════════════════════════════════════════
// 제출 현황
// ═══════════════════════════════════════════════════════════════
{
- header: "제출 현황",
+ header: "협력업체 제출 현황",
columns: [
{
accessorKey: "documentsSubmitted",
@@ -266,6 +367,8 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
]
},
+
+
// ═══════════════════════════════════════════════════════════════
// 평가 점수
// ═══════════════════════════════════════════════════════════════
@@ -273,12 +376,12 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: "평가 점수",
columns: [
{
- accessorKey: "totalScore",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="총점" />,
+ accessorKey: "processScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="공정" />,
cell: ({ row }) => {
- const score = row.getValue<number>("totalScore");
+ const score = row.getValue("processScore");
return score ? (
- <span className="font-medium">{score.toFixed(1)}</span>
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
);
@@ -287,156 +390,176 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
{
- accessorKey: "evaluationGrade",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등급" />,
+ accessorKey: "priceScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="가격" />,
cell: ({ row }) => {
- const grade = row.getValue<string>("evaluationGrade");
- return grade ? (
- <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge>
+ const score = row.getValue("priceScore");
+ return score ? (
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 60,
+ size: 80,
},
-
+
{
- accessorKey: "finalScore",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종점수" />,
+ accessorKey: "deliveryScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="납기" />,
cell: ({ row }) => {
- const finalScore = row.getValue<number>("finalScore");
- return finalScore ? (
- <span className="font-bold text-green-600">{finalScore.toFixed(1)}</span>
+ const score = row.getValue("deliveryScore");
+ return score ? (
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 90,
+ size: 80,
+ },
+
+ {
+ accessorKey: "selfEvaluationScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자율평가" />,
+ cell: ({ row }) => {
+ const score = row.getValue("selfEvaluationScore");
+ return score ? (
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
},
+ // ✅ 합계 - 4개 점수의 합으로 계산
{
- accessorKey: "finalGrade",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종등급" />,
+ id: "totalScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="합계" />,
cell: ({ row }) => {
- const finalGrade = row.getValue<string>("finalGrade");
- return finalGrade ? (
- <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600">
- {finalGrade}
- </Badge>
+ const processScore = Number(row.getValue("processScore") || 0);
+ const priceScore = Number(row.getValue("priceScore") || 0);
+ const deliveryScore = Number(row.getValue("deliveryScore") || 0);
+ const selfEvaluationScore = Number(row.getValue("selfEvaluationScore") || 0);
+
+ const total = processScore + priceScore + deliveryScore + selfEvaluationScore;
+
+ return total > 0 ? (
+ <span className="font-medium bg-blue-50 px-2 py-1 rounded">
+ {total.toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 90,
+ size: 80,
},
- ]
- },
- // ═══════════════════════════════════════════════════════════════
- // 진행 현황
- // ═══════════════════════════════════════════════════════════════
- {
- header: "진행 현황",
- columns: [
{
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ accessorKey: "participationBonus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여도(가점)" />,
cell: ({ row }) => {
- const status = row.getValue<string>("status");
- return (
- <Badge variant={getStatusBadgeVariant(status)}>
- {getStatusLabel(status)}
- </Badge>
+ const score = row.getValue("participationBonus");
+ return score ? (
+ <span className="font-medium text-green-600">+{Number(score).toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
);
},
size: 100,
},
{
- id: "reviewProgress",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
+ accessorKey: "qualityDeduction",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질(감점)" />,
cell: ({ row }) => {
- const totalReviewers = row.original.totalReviewers || 0;
- const completedReviewers = row.original.completedReviewers || 0;
-
- return getProgressBadge(completedReviewers, totalReviewers);
+ const score = row.getValue("qualityDeduction");
+ return score ? (
+ <span className="font-medium text-red-600">-{Number(score).toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
},
- size: 120,
+ size: 100,
},
+ // ✅ 새로운 평가점수 컬럼 추가
{
- accessorKey: "reviewCompletedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />,
+ id: "evaluationScore",
+ 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))}
+ const processScore = Number(row.getValue("processScore") || 0);
+ const priceScore = Number(row.getValue("priceScore") || 0);
+ const deliveryScore = Number(row.getValue("deliveryScore") || 0);
+ const selfEvaluationScore = Number(row.getValue("selfEvaluationScore") || 0);
+ const participationBonus = Number(row.getValue("participationBonus") || 0);
+ const qualityDeduction = Number(row.getValue("qualityDeduction") || 0);
+
+ const totalScore = processScore + priceScore + deliveryScore + selfEvaluationScore;
+ const evaluationScore = totalScore + participationBonus - qualityDeduction;
+
+ return totalScore > 0 ? (
+ <span className="font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">
+ {evaluationScore.toFixed(1)}
</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 90,
},
{
- accessorKey: "finalizedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
+ accessorKey: "evaluationGrade",
+ 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>
+ const grade = row.getValue<string>("evaluationGrade");
+ return grade ? (
+ <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 80,
},
+
]
},
+
+
// ░░░ 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>
+
+ // <Button
+ // variant="ghost"
+ // size="icon"
+ // className="size-8"
+ // onClick={() => setRowAction({ row, type: "update" })}
+ // aria-label="수정"
+ // title="수정"
+ // >
+ // <Pencil className="size-4" />
+ // </Button>
+ // </div>
+ // );
+ // },
+ // },
];
} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index a628475d..9e32debb 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -23,7 +23,8 @@ import { useMemo } from "react"
import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
import { PeriodicEvaluationView } from "@/db/schema"
-import { getPeriodicEvaluations } from "../service"
+import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service"
+import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions"
interface PeriodicEvaluationsTableProps {
promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]>
@@ -44,17 +45,9 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
try {
setIsLoading(true)
setError(null)
- // TODO: getPeriodicEvaluationsStats 구현 필요
- const statsData = {
- total: 150,
- pendingSubmission: 25,
- submitted: 45,
- inReview: 30,
- reviewCompleted: 35,
- finalized: 15,
- averageScore: 82.5,
- completionRate: 75
- }
+
+ // 실제 통계 함수 호출
+ const statsData = await getPeriodicEvaluationsStats(evaluationYear)
if (isMounted) {
setStats(statsData)
@@ -76,7 +69,7 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
return () => {
isMounted = false
}
- }, [])
+ }, [evaluationYear]) // evaluationYear 의존성 추가
if (isLoading) {
return (
@@ -230,6 +223,8 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const [promiseData] = React.use(promises)
const tableData = promiseData
+ console.log(tableData)
+
const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
return searchParams?.get(key) ?? defaultValue ?? "";
}, [searchParams]);
@@ -453,6 +448,9 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
onRenamePreset={renamePreset}
/>
+ <PeriodicEvaluationsTableToolbarActions
+ table={table}
+ />
{/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */}
</div>
</DataTableAdvancedToolbar>
diff --git a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
new file mode 100644
index 00000000..30ff9535
--- /dev/null
+++ b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
@@ -0,0 +1,373 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+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 { toast } from "sonner"
+import { PeriodicEvaluationView } from "@/db/schema"
+import { checkExistingSubmissions, requestDocumentsFromVendors } from "../service"
+
+
+// ================================================================
+// 2. 협력업체 자료 요청 다이얼로그
+// ================================================================
+interface RequestDocumentsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluations: PeriodicEvaluationView[]
+ onSuccess: () => void
+}
+
+interface EvaluationWithSubmissionStatus extends PeriodicEvaluationView {
+ hasExistingSubmission?: boolean
+ submissionDate?: Date | null
+}
+
+export function RequestDocumentsDialog({
+ open,
+ onOpenChange,
+ evaluations,
+ onSuccess,
+}: RequestDocumentsDialogProps) {
+
+ console.log(evaluations)
+
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isCheckingStatus, setIsCheckingStatus] = React.useState(false)
+ const [message, setMessage] = React.useState("")
+ const [evaluationsWithStatus, setEvaluationsWithStatus] = React.useState<EvaluationWithSubmissionStatus[]>([])
+
+ // 제출대기 상태인 평가들만 필터링
+ const pendingEvaluations = React.useMemo(() =>
+ evaluations.filter(e => e.status === "PENDING_SUBMISSION"),
+ [evaluations]
+ )
+
+ React.useEffect(() => {
+ if (!open) return;
+
+ // 대기 중 평가가 없으면 초기화
+ if (pendingEvaluations.length === 0) {
+ setEvaluationsWithStatus([]);
+ setIsCheckingStatus(false);
+ return;
+ }
+
+ // 상태 확인
+ (async () => {
+ setIsCheckingStatus(true);
+ try {
+ const ids = pendingEvaluations.map(e => e.id);
+ const existing = await checkExistingSubmissions(ids);
+
+ setEvaluationsWithStatus(
+ pendingEvaluations.map(e => ({
+ ...e,
+ hasExistingSubmission: existing.some(s => s.periodicEvaluationId === e.id),
+ submissionDate: existing.find(s => s.periodicEvaluationId === e.id)?.createdAt ?? null,
+ })),
+ );
+ } catch (err) {
+ console.error(err);
+ setEvaluationsWithStatus(
+ pendingEvaluations.map(e => ({ ...e, hasExistingSubmission: false })),
+ );
+ } finally {
+ setIsCheckingStatus(false);
+ }
+ })();
+ }, [open, pendingEvaluations]); // 함수 대신 값에만 의존
+
+ // 새 요청과 재요청 분리
+ const newRequests = evaluationsWithStatus.filter(e => !e.hasExistingSubmission)
+ const reRequests = evaluationsWithStatus.filter(e => e.hasExistingSubmission)
+
+ const handleSubmit = async () => {
+ if (!message.trim()) {
+ toast.error("요청 메시지를 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버 액션 데이터 준비
+ const requestData = evaluationsWithStatus.map(evaluation => ({
+ periodicEvaluationId: evaluation.id,
+ companyId: evaluation.vendorId,
+ evaluationYear: evaluation.evaluationYear,
+ evaluationRound: evaluation.evaluationPeriod,
+ message: message.trim()
+ }))
+
+ // 서버 액션 호출
+ const result = await requestDocumentsFromVendors(requestData)
+
+ if (result.success) {
+ toast.success(result.message)
+ onSuccess()
+ onOpenChange(false)
+ setMessage("")
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('Error requesting documents:', error)
+ toast.error("자료 요청 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="size-4" />
+ 협력업체 자료 요청
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 평가의 협력업체들에게 평가 자료 제출을 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {isCheckingStatus ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">요청 상태를 확인하고 있습니다...</div>
+ </div>
+ ) : (
+ <>
+ {/* 신규 요청 대상 업체 */}
+ {newRequests.length > 0 && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm text-blue-600">
+ 신규 요청 대상 ({newRequests.length}개 업체)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 max-h-32 overflow-y-auto">
+ {newRequests.map((evaluation) => (
+ <div
+ key={evaluation.id}
+ className="flex items-center justify-between text-sm p-2 bg-blue-50 rounded"
+ >
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <div className="flex gap-2">
+ <Badge variant="outline">{evaluation.vendorCode}</Badge>
+ <Badge variant="default" className="bg-blue-600">신규</Badge>
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 재요청 대상 업체 */}
+ {reRequests.length > 0 && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm text-orange-600">
+ 재요청 대상 ({reRequests.length}개 업체)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 max-h-32 overflow-y-auto">
+ {reRequests.map((evaluation) => (
+ <div
+ key={evaluation.id}
+ className="flex items-center justify-between text-sm p-2 bg-orange-50 rounded"
+ >
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <div className="flex gap-2">
+ <Badge variant="outline">{evaluation.vendorCode}</Badge>
+ <Badge variant="secondary" className="bg-orange-100">
+ 재요청
+ </Badge>
+ {evaluation.submissionDate && (
+ <span className="text-xs text-muted-foreground">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(evaluation.submissionDate))}
+ </span>
+ )}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 요청 대상이 없는 경우 */}
+ {!isCheckingStatus && evaluationsWithStatus.length === 0 && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="text-center text-sm text-muted-foreground">
+ 요청할 수 있는 평가가 없습니다.
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </>
+ )}
+
+ {/* 요청 메시지 */}
+ <div className="space-y-2">
+ <Label htmlFor="message">요청 메시지</Label>
+ <Textarea
+ id="message"
+ placeholder="협력업체에게 전달할 메시지를 입력하세요..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ rows={4}
+ disabled={isCheckingStatus}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading || isCheckingStatus}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || isCheckingStatus || evaluationsWithStatus.length === 0}
+ >
+ <Send className="size-4 mr-2" />
+ {isLoading ? "발송 중..." :
+ `요청 발송 (신규 ${newRequests.length}개${reRequests.length > 0 ? `, 재요청 ${reRequests.length}개` : ''})`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+
+
+// ================================================================
+// 3. 평가자 평가 요청 다이얼로그
+// ================================================================
+interface RequestEvaluationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluations: PeriodicEvaluationView[]
+ onSuccess: () => void
+}
+
+export function RequestEvaluationDialog({
+ open,
+ onOpenChange,
+ evaluations,
+ onSuccess,
+}: RequestEvaluationDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [message, setMessage] = React.useState("")
+
+ // 제출완료 상태인 평가들만 필터링
+ const submittedEvaluations = evaluations.filter(e => e.status === "SUBMITTED")
+
+ const handleSubmit = async () => {
+ if (!message.trim()) {
+ toast.error("요청 메시지를 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // TODO: 평가자들에게 평가 요청 API 호출
+ toast.success(`${submittedEvaluations.length}개 평가에 대한 평가 요청이 발송되었습니다.`)
+ onSuccess()
+ onOpenChange(false)
+ setMessage("")
+ } catch (error) {
+ console.error('Error requesting evaluation:', error)
+ toast.error("평가 요청 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-4" />
+ 평가자 평가 요청
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 평가들에 대해 평가자들에게 평가를 요청합니다.
+ </DialogDescription>
+ </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>
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ {/* 요청 메시지 */}
+ <div className="space-y-2">
+ <Label htmlFor="evaluation-message">요청 메시지</Label>
+ <Textarea
+ id="evaluation-message"
+ placeholder="평가자들에게 전달할 메시지를 입력하세요..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ rows={4}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isLoading}>
+ <Send className="size-4 mr-2" />
+ {isLoading ? "발송 중..." : `${submittedEvaluations.length}개 평가 요청`}
+ </Button>
+ </DialogFooter>
+ </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
new file mode 100644
index 00000000..2d2bebc1
--- /dev/null
+++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
@@ -0,0 +1,218 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Plus,
+ Send,
+ Users,
+ Download,
+ RefreshCw,
+ FileText,
+ MessageSquare
+} from "lucide-react"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ RequestDocumentsDialog,
+ RequestEvaluationDialog,
+} from "./periodic-evaluation-action-dialogs"
+import { PeriodicEvaluationView } from "@/db/schema"
+import { exportTableToExcel } from "@/lib/export"
+
+interface PeriodicEvaluationsTableToolbarActionsProps {
+ table: Table<PeriodicEvaluationView>
+ onRefresh?: () => void
+}
+
+export function PeriodicEvaluationsTableToolbarActions({
+ table,
+ onRefresh
+}: PeriodicEvaluationsTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [createEvaluationDialogOpen, setCreateEvaluationDialogOpen] = React.useState(false)
+ const [requestDocumentsDialogOpen, setRequestDocumentsDialogOpen] = React.useState(false)
+ const [requestEvaluationDialogOpen, setRequestEvaluationDialogOpen] = React.useState(false)
+ const router = useRouter()
+
+ // 선택된 행들
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const hasSelection = selectedRows.length > 0
+ const selectedEvaluations = selectedRows.map(row => row.original)
+
+ // 선택된 항목들의 상태 분석
+ 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
+
+ // 협력업체에게 자료 요청 가능: PENDING_SUBMISSION 상태
+ const canRequestDocuments = pendingSubmission > 0
+
+ // 평가자에게 평가 요청 가능: SUBMITTED 상태 (제출됐지만 아직 평가 시작 안됨)
+ const canRequestEvaluation = submitted > 0
+
+ return {
+ pendingSubmission,
+ submitted,
+ inReview,
+ reviewCompleted,
+ finalized,
+ canRequestDocuments,
+ canRequestEvaluation,
+ 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)
+ }
+
+ // ----------------------------------------------------------------
+ // 다이얼로그 성공 핸들러
+ // ----------------------------------------------------------------
+ const handleActionSuccess = () => {
+ table.resetRowSelection()
+ onRefresh?.()
+ router.refresh()
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+
+ {/* 유틸리티 버튼들 */}
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "periodic-evaluations",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+
+ {/* 선택된 항목 액션 버튼들 */}
+ {hasSelection && (
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ {/* 협력업체 자료 요청 버튼 */}
+ {selectedStats.canRequestDocuments && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2 text-blue-600 border-blue-200 hover:bg-blue-50"
+ onClick={() => setRequestDocumentsDialogOpen(true)}
+ disabled={isLoading}
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 자료 요청 ({selectedStats.pendingSubmission})
+ </span>
+ </Button>
+ )}
+
+ {/* 평가자 평가 요청 버튼 */}
+ {selectedStats.canRequestEvaluation && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2 text-green-600 border-green-200 hover:bg-green-50"
+ onClick={() => setRequestEvaluationDialogOpen(true)}
+ disabled={isLoading}
+ >
+ <Users className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 평가 요청 ({selectedStats.submitted})
+ </span>
+ </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>
+ </div>
+ )}
+ </div>
+
+
+ {/* 협력업체 자료 요청 다이얼로그 */}
+ <RequestDocumentsDialog
+ open={requestDocumentsDialogOpen}
+ onOpenChange={setRequestDocumentsDialogOpen}
+ evaluations={selectedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 평가자 평가 요청 다이얼로그 */}
+ <RequestEvaluationDialog
+ open={requestEvaluationDialogOpen}
+ onOpenChange={setRequestEvaluationDialogOpen}
+ evaluations={selectedEvaluations}
+ 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>
+ )} */}
+ </>
+ )
+} \ No newline at end of file