summaryrefslogtreecommitdiff
path: root/lib/evaluation
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation')
-rw-r--r--lib/evaluation/service.ts214
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx455
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx616
-rw-r--r--lib/evaluation/table/evaluation-table.tsx341
-rw-r--r--lib/evaluation/table/evaluation-view-toggle.tsx88
-rw-r--r--lib/evaluation/validation.ts13
6 files changed, 1056 insertions, 671 deletions
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index c49521da..9889a110 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -6,6 +6,7 @@ import {
evaluationTargetReviewers,
evaluationTargets,
periodicEvaluations,
+ periodicEvaluationsAggregatedView,
periodicEvaluationsView,
regEvalCriteria,
regEvalCriteriaDetails,
@@ -55,15 +56,6 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
});
}
- // 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;
@@ -102,7 +94,6 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
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;
@@ -157,7 +148,7 @@ export interface PeriodicEvaluationsStats {
inReview: number
reviewCompleted: number
finalized: number
- averageScore: number | null
+ // averageScore: number | null
completionRate: number
averageFinalScore: number | null
documentsSubmittedCount: number
@@ -179,7 +170,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi
const totalStatsResult = await db
.select({
total: count(),
- averageScore: avg(periodicEvaluationsView.totalScore),
+ // averageScore: avg(periodicEvaluationsView.totalScore),
averageFinalScore: avg(periodicEvaluationsView.finalScore),
})
.from(periodicEvaluationsView)
@@ -187,7 +178,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi
const totalStats = totalStatsResult[0] || {
total: 0,
- averageScore: null,
+ // averageScore: null,
averageFinalScore: null
}
@@ -265,7 +256,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi
inReview: statusCounts['IN_REVIEW'] || 0,
reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0,
finalized: finalizedCount,
- averageScore: formatScore(totalStats.averageScore),
+ // averageScore: formatScore(totalStats.averageScore),
averageFinalScore: formatScore(totalStats.averageFinalScore),
completionRate,
documentsSubmittedCount: documentCounts.submitted,
@@ -288,7 +279,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi
inReview: 0,
reviewCompleted: 0,
finalized: 0,
- averageScore: null,
+ // averageScore: null,
averageFinalScore: null,
completionRate: 0,
documentsSubmittedCount: 0,
@@ -569,6 +560,7 @@ export async function getReviewersForEvaluations(
)
.orderBy(evaluationTargetReviewers.evaluationTargetId, users.name)
+
// 2. 추가: role name에 "정기평가"가 포함된 사용자들
const roleBasedReviewers = await db
.select({
@@ -605,28 +597,11 @@ export async function getReviewersForEvaluations(
}
}
- // 4. 중복 제거 (같은 사용자가 designated reviewer와 role-based reviewer 모두에 있을 수 있음)
+ // 4. 모든 리뷰어 합치기 (중복 제거 없이)
const allReviewers = [...designatedReviewers, ...expandedRoleBasedReviewers]
- // evaluationTargetId + userId 조합으로 중복 제거
- const uniqueReviewers = allReviewers.reduce((acc, reviewer) => {
- const key = `${reviewer.evaluationTargetId}-${reviewer.id}`
-
- // 이미 있는 경우 designated reviewer를 우선 (evaluationTargetReviewerId가 양수인 것)
- if (acc[key]) {
- if (reviewer.evaluationTargetReviewerId > 0) {
- acc[key] = reviewer // designated reviewer로 교체
- }
- // 이미 designated reviewer가 있으면 role-based는 무시
- } else {
- acc[key] = reviewer
- }
-
- return acc
- }, {} as Record<string, ReviewerInfo>)
-
- return Object.values(uniqueReviewers).sort((a, b) => {
- // evaluationTargetId로 먼저 정렬, 그 다음 이름으로 정렬
+ // 정렬만 수행 (evaluationTargetId로 먼저 정렬, 그 다음 이름으로 정렬)
+ return allReviewers.sort((a, b) => {
if (a.evaluationTargetId !== b.evaluationTargetId) {
return a.evaluationTargetId - b.evaluationTargetId
}
@@ -685,6 +660,8 @@ export async function createReviewerEvaluationsRequest(
)
)
+ console.log(newRequestData,"newRequestData")
+
if (newRequestData.length === 0) {
throw new Error("모든 평가 요청이 이미 생성되어 있습니다.")
}
@@ -1320,4 +1297,171 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
: "평가 상세 정보 조회 중 오류가 발생했습니다"
)
}
+}
+
+
+export async function getPeriodicEvaluationsAggregated(input: GetEvaluationTargetsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 조건 (기존과 동일)
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: periodicEvaluationsAggregatedView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
+
+ // 2) 글로벌 검색 조건 (집계 뷰에 맞게 조정)
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ // 벤더 정보로 검색
+ const vendorCodeCondition = ilike(periodicEvaluationsAggregatedView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorNameCondition = ilike(periodicEvaluationsAggregatedView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ // 평가 관련 코멘트로 검색
+ const evaluationNoteCondition = ilike(periodicEvaluationsAggregatedView.evaluationNote, s);
+ if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition);
+
+ const adminCommentCondition = ilike(periodicEvaluationsAggregatedView.evaluationTargetAdminComment, s);
+ if (adminCommentCondition) validSearchConditions.push(adminCommentCondition);
+
+ const consolidatedCommentCondition = ilike(periodicEvaluationsAggregatedView.evaluationTargetConsolidatedComment, s);
+ if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition);
+
+ // 최종 확정자 이름으로 검색
+ const finalizedByUserNameCondition = ilike(periodicEvaluationsAggregatedView.finalizedByUserName, s);
+ if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // 3) WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
+
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 4) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(periodicEvaluationsAggregatedView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("Total aggregated periodic evaluations:", total);
+
+ // 5) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof periodicEvaluationsAggregatedView.$inferSelect;
+ return sort.desc ? desc(periodicEvaluationsAggregatedView[column]) : asc(periodicEvaluationsAggregatedView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(periodicEvaluationsAggregatedView.createdAt));
+ }
+
+ const periodicEvaluationsData = await db
+ .select()
+ .from(periodicEvaluationsAggregatedView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: periodicEvaluationsData, pageCount, total };
+
+ } catch (err) {
+ console.error("Error in getPeriodicEvaluationsAggregated:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+}
+
+// 기존 함수에 집계 옵션을 추가한 통합 함수
+export async function getPeriodicEvaluationsWithAggregation(input: GetEvaluationTargetsSchema) {
+ if (input.aggregated) {
+ return getPeriodicEvaluationsAggregated(input);
+ } else {
+ return getPeriodicEvaluations(input);
+ }
+}
+
+// 집계된 주기평가 통계 함수
+export async function getPeriodicEvaluationsAggregatedStats(evaluationYear: number) {
+ try {
+ const statsQuery = await db
+ .select({
+ total: count(),
+ pendingSubmission: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'PENDING_SUBMISSION' THEN 1 END)::int`,
+ submitted: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'SUBMITTED' THEN 1 END)::int`,
+ inReview: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'IN_REVIEW' THEN 1 END)::int`,
+ reviewCompleted: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'REVIEW_COMPLETED' THEN 1 END)::int`,
+ finalized: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'FINALIZED' THEN 1 END)::int`,
+ averageScore: sql<number>`ROUND(AVG(NULLIF(${periodicEvaluationsAggregatedView.finalScore}, 0)), 1)`,
+ totalEvaluationCount: sql<number>`SUM(${periodicEvaluationsAggregatedView.evaluationCount})::int`,
+ })
+ .from(periodicEvaluationsAggregatedView)
+ .where(eq(periodicEvaluationsAggregatedView.evaluationYear, evaluationYear));
+
+ const stats = statsQuery[0];
+
+ if (!stats) {
+ return {
+ total: 0,
+ pendingSubmission: 0,
+ submitted: 0,
+ inReview: 0,
+ reviewCompleted: 0,
+ finalized: 0,
+ completionRate: 0,
+ averageScore: 0,
+ totalEvaluationCount: 0,
+ };
+ }
+
+ const completionRate = stats.total > 0
+ ? Math.round((stats.finalized / stats.total) * 100)
+ : 0;
+
+ return {
+ ...stats,
+ completionRate,
+ };
+
+ } catch (error) {
+ console.error('Error fetching aggregated periodic evaluations stats:', error);
+ throw error;
+ }
+}
+
+// 집계 모드에 따른 통계 조회 함수
+export async function getPeriodicEvaluationsStatsWithMode(
+ evaluationYear: number,
+ aggregated: boolean = false
+) {
+ if (aggregated) {
+ return getPeriodicEvaluationsAggregatedStats(evaluationYear);
+ } else {
+ return getPeriodicEvaluationsStats(evaluationYear);
+ }
} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index dca19ddb..e8b51b57 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -1,6 +1,4 @@
-// ================================================================
-// 1. PERIODIC EVALUATIONS COLUMNS
-// ================================================================
+// components/evaluation/evaluation-columns.tsx - 집계 모드 지원 업데이트
"use client";
import * as React from "react";
@@ -8,33 +6,69 @@ 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, Circle, Ellipsis } from "lucide-react";
+import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3 } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
-import { PeriodicEvaluationView } from "@/db/schema";
+import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
import { vendortypeMap } from "@/types/evaluation";
-
-
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PeriodicEvaluationView> | null>>;
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> | null>>;
+ viewMode?: "detailed" | "aggregated";
}
-// 상태별 색상 매핑
+// 집계 모드용 division 배지
+const getDivisionBadgeWithAggregation = (
+ division: string,
+ evaluationCount?: number,
+ divisions?: string
+) => {
+ if (division === "BOTH") {
+ return (
+ <div className="flex items-center gap-1">
+ <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">
+ 통합
+ </Badge>
+ {evaluationCount && evaluationCount > 1 && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="secondary" className="text-xs">
+ {evaluationCount}개
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{divisions?.replace(',', ', ')} 평가 통합</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ );
+ }
+
+ return (
+ <Badge variant={division === "PLANT" ? "default" : "secondary"}>
+ {division === "PLANT" ? "해양" : "조선"}
+ </Badge>
+ );
+};
+
+// 기존 함수들은 그대로 유지...
const getStatusBadgeVariant = (status: string) => {
switch (status) {
- case "PENDING_SUBMISSION":
- return "outline";
- case "SUBMITTED":
- return "secondary";
- case "IN_REVIEW":
- return "default";
- case "REVIEW_COMPLETED":
- return "default";
- case "FINALIZED":
- return "default";
- default:
- return "outline";
+ case "PENDING_SUBMISSION": return "outline";
+ case "SUBMITTED": return "secondary";
+ case "IN_REVIEW": return "default";
+ case "REVIEW_COMPLETED": return "default";
+ case "FINALIZED": return "default";
+ default: return "outline";
}
};
@@ -54,7 +88,6 @@ const getStatusLabel = (status: string) => {
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>
);
@@ -63,7 +96,6 @@ const getDepartmentStatusBadge = (status: string | null) => {
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>
);
@@ -71,73 +103,44 @@ const getDepartmentStatusBadge = (status: string | null) => {
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";
switch (grade) {
- case "S":
- return "default";
- case "A":
- return "secondary";
- case "B":
- return "outline";
- case "C":
- return "destructive";
- case "D":
- return "destructive";
- default:
- return "outline";
+ case "S": return "default";
+ case "A": return "secondary";
+ case "B": return "outline";
+ case "C": return "destructive";
+ case "D": return "destructive";
+ default: return "outline";
}
};
-// 구분 배지
-const getDivisionBadge = (division: string) => {
- return (
- <Badge variant={division === "PLANT" ? "default" : "secondary"}>
- {division === "PLANT" ? "해양" : "조선"}
- </Badge>
- );
-};
-
// 자재구분 배지
const getMaterialTypeBadge = (materialType: string) => {
-
return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>;
};
@@ -150,18 +153,12 @@ const getDomesticForeignBadge = (domesticForeign: string) => {
);
};
-// 진행률 배지
-const getProgressBadge = (completed: number, total: number) => {
- if (total === 0) return <Badge variant="outline">-</Badge>;
+export function getPeriodicEvaluationsColumns({
+ setRowAction,
+ viewMode = "detailed"
+}: GetColumnsProps): ColumnDef<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] {
- const percentage = Math.round((completed / total) * 100);
- const variant = percentage === 100 ? "default" : percentage >= 50 ? "secondary" : "destructive";
-
- return <Badge variant={variant}>{completed}/{total} ({percentage}%)</Badge>;
-};
-
-export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationView>[] {
- return [
+ const baseColumns: ColumnDef<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [
// ═══════════════════════════════════════════════════════════════
// 선택 및 기본 정보
// ═══════════════════════════════════════════════════════════════
@@ -196,24 +193,37 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />,
cell: ({ row }) => <span className="font-medium">{row.original.evaluationYear}</span>,
size: 100,
+ meta: {
+ excelHeader: "평가년도",
+ },
},
- // ░░░ 평가기간 ░░░
- // {
- // accessorKey: "evaluationPeriod",
- // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />,
- // cell: ({ row }) => (
- // <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge>
- // ),
- // size: 100,
- // },
-
- // ░░░ 구분 ░░░
+ // ░░░ 구분 ░░░ - 집계 모드에 따라 다르게 렌더링
{
accessorKey: "division",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
- cell: ({ row }) => getDivisionBadge(row.original.division || ""),
- size: 80,
+ cell: ({ row }) => {
+ const division =viewMode === "aggregated"?"BOTH": row.original.division || "";
+
+ if (viewMode === "aggregated") {
+ const aggregatedRow = row.original as PeriodicEvaluationAggregatedView;
+ return getDivisionBadgeWithAggregation(
+ division,
+ aggregatedRow.evaluationCount,
+ aggregatedRow.divisions
+ );
+ }
+
+ return (
+ <Badge variant={division === "PLANT" ? "default" : "secondary"}>
+ {division === "PLANT" ? "해양" : "조선"}
+ </Badge>
+ );
+ },
+ size: viewMode === "aggregated" ? 120 : 80,
+ meta: {
+ excelHeader: "구분",
+ },
},
{
@@ -221,9 +231,11 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Status" />,
cell: ({ row }) => getStatusLabel(row.original.status || ""),
size: 80,
+ meta: {
+ excelHeader: "Status",
+ },
},
-
// ═══════════════════════════════════════════════════════════════
// 협력업체 정보
// ═══════════════════════════════════════════════════════════════
@@ -237,6 +249,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
<span className="font-mono text-sm">{row.original.vendorCode}</span>
),
size: 120,
+ meta: {
+ excelHeader: "벤더 코드",
+ },
},
{
@@ -248,6 +263,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
</div>
),
size: 200,
+ meta: {
+ excelHeader: "벤더명",
+ },
},
{
@@ -255,6 +273,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
cell: ({ row }) => getDomesticForeignBadge(row.original.domesticForeign || ""),
size: 80,
+ meta: {
+ excelHeader: "내외자",
+ },
},
{
@@ -262,22 +283,87 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
cell: ({ row }) => getMaterialTypeBadge(row.original.materialType || ""),
size: 120,
+ meta: {
+ excelHeader: "자재구분",
+ },
},
]
},
+
+ // 집계 모드에서만 보이는 평가 개수 컬럼
+ ...(viewMode === "aggregated" ? [{
+ accessorKey: "evaluationCount",
+ header: ({ column }) => (
+ <div className="flex items-center gap-1">
+ <BarChart3 className="h-4 w-4" />
+ <DataTableColumnHeaderSimple column={column} title="평가수" />
+ </div>
+ ),
+ cell: ({ row }) => {
+ const aggregatedRow = row.original as PeriodicEvaluationAggregatedView;
+ const count = aggregatedRow.evaluationCount || 1;
+
+ return (
+ <div className="flex items-center gap-1">
+ <Badge variant={count > 1 ? "default" : "outline"} className="font-mono">
+ {count}개
+ </Badge>
+ {count > 1 && aggregatedRow.divisions && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <div className="text-xs text-muted-foreground cursor-help">
+ ({aggregatedRow.divisions.replace(',', ', ')})
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{aggregatedRow.divisions.replace(',', ', ')} 평가의 평균값</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ );
+ },
+ size: 100,
+ meta: {
+ excelHeader: "평가수",
+ },
+ }] : []),
{
accessorKey: "finalScore",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정점수" />,
cell: ({ row }) => {
const finalScore = row.getValue<number>("finalScore");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return finalScore ? (
- <span className="font-bold text-green-600">{Number(finalScore).toFixed(1)}</span>
+ <div className="flex items-center gap-1">
+ <span className={`font-bold ${isAggregated ? 'text-purple-600' : 'text-green-600'}`}>
+ {Number(finalScore).toFixed(1)}
+ </span>
+ {isAggregated && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="outline" className="text-xs bg-purple-50">평균</Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>여러 평가의 평균값</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 90,
+ size: viewMode === "aggregated" ? 120 : 90,
+ meta: {
+ excelHeader: "확정점수",
+ },
},
{
@@ -285,8 +371,13 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정등급" />,
cell: ({ row }) => {
const finalGrade = row.getValue<string>("finalGrade");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return finalGrade ? (
- <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600">
+ <Badge
+ variant={getGradeBadgeVariant(finalGrade)}
+ className={isAggregated ? "bg-purple-600" : "bg-green-600"}
+ >
{finalGrade}
</Badge>
) : (
@@ -294,10 +385,13 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
);
},
size: 90,
+ meta: {
+ excelHeader: "확정등급",
+ },
},
- // ═══════════════════════════════════════════════════════════════
- // 진행 현황
+ // ═══════════════════════════════════════════════════════════════
+ // 진행 현황 - 집계 모드에서는 최고 진행 상태를 보여줌
// ═══════════════════════════════════════════════════════════════
{
header: "부서별 평가 현황",
@@ -307,6 +401,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발주" />,
cell: ({ row }) => getDepartmentStatusBadge(row.getValue("orderEvalStatus")),
size: 60,
+ meta: {
+ excelHeader: "발주",
+ },
},
{
@@ -314,6 +411,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조달" />,
cell: ({ row }) => getDepartmentStatusBadge(row.getValue("procurementEvalStatus")),
size: 70,
+ meta: {
+ excelHeader: "조달",
+ },
},
{
@@ -321,6 +421,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질" />,
cell: ({ row }) => getDepartmentStatusBadge(row.getValue("qualityEvalStatus")),
size: 70,
+ meta: {
+ excelHeader: "품질",
+ },
},
{
@@ -328,6 +431,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계" />,
cell: ({ row }) => getDepartmentStatusBadge(row.getValue("designEvalStatus")),
size: 70,
+ meta: {
+ excelHeader: "설계",
+ },
},
{
@@ -335,13 +441,24 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="CS" />,
cell: ({ row }) => getDepartmentStatusBadge(row.getValue("csEvalStatus")),
size: 70,
+ meta: {
+ excelHeader: "CS",
+ },
+ },
+
+ {
+ accessorKey: "adminEvalStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자" />,
+ cell: ({ row }) => getDepartmentStatusBadge(row.getValue("adminEvalStatus")),
+ size: 120,
+ meta: {
+ excelHeader: "관리자",
+ },
},
]
},
{
- // id: "평가상세",
- // accessorKey: "평가상세",
header: "평가상세",
enableHiding: true,
size: 80,
@@ -359,11 +476,11 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
>
<Ellipsis className="size-4" />
</Button>
-
</div>
);
},
},
+
// ═══════════════════════════════════════════════════════════════
// 제출 현황
// ═══════════════════════════════════════════════════════════════
@@ -375,13 +492,32 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="문서제출" />,
cell: ({ row }) => {
const submitted = row.getValue<boolean>("documentsSubmitted");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return (
- <Badge variant={submitted ? "default" : "destructive"}>
- {submitted ? "제출완료" : "미제출"}
- </Badge>
+ <div className="flex items-center gap-1">
+ <Badge variant={submitted ? "default" : "destructive"}>
+ {submitted ? "제출완료" : "미제출"}
+ </Badge>
+ {isAggregated && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="outline" className="text-xs">통합</Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>모든 평가에서 제출 완료된 경우만 "제출완료"</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
);
},
- size: 120,
+ size: viewMode === "aggregated" ? 140 : 120,
+ meta: {
+ excelHeader: "문서제출",
+ },
},
{
@@ -389,18 +525,37 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="제출일" />,
cell: ({ row }) => {
const submissionDate = row.getValue<Date>("submissionDate");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return submissionDate ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(submissionDate))}
- </span>
+ <div className="flex items-center gap-1">
+ <span className="text-sm">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(submissionDate))}
+ </span>
+ {isAggregated && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="outline" className="text-xs">최신</Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>가장 최근 제출일</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 80,
+ size: viewMode === "aggregated" ? 110 : 80,
+ meta: {
+ excelHeader: "제출일",
+ },
},
{
@@ -423,30 +578,38 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
);
},
size: 80,
+ meta: {
+ excelHeader: "마감일",
+ },
},
]
},
-
-
// ═══════════════════════════════════════════════════════════════
- // 평가 점수
+ // 평가 점수 - 집계 모드에서는 평균임을 명시
// ═══════════════════════════════════════════════════════════════
{
- header: "평가 점수",
+ header: viewMode === "aggregated" ? "평가 점수 (평균)" : "평가 점수",
columns: [
{
accessorKey: "processScore",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="공정" />,
cell: ({ row }) => {
const score = row.getValue("processScore");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return score ? (
- <span className="font-medium">{Number(score).toFixed(1)}</span>
+ <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}>
+ {Number(score).toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 80,
+ meta: {
+ excelHeader: "공정",
+ },
},
{
@@ -454,13 +617,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="가격" />,
cell: ({ row }) => {
const score = row.getValue("priceScore");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return score ? (
- <span className="font-medium">{Number(score).toFixed(1)}</span>
+ <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}>
+ {Number(score).toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 80,
+ meta: {
+ excelHeader: "가격",
+ },
},
{
@@ -468,13 +638,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="납기" />,
cell: ({ row }) => {
const score = row.getValue("deliveryScore");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return score ? (
- <span className="font-medium">{Number(score).toFixed(1)}</span>
+ <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}>
+ {Number(score).toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 80,
+ meta: {
+ excelHeader: "납기",
+ },
},
{
@@ -482,13 +659,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자율평가" />,
cell: ({ row }) => {
const score = row.getValue("selfEvaluationScore");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return score ? (
- <span className="font-medium">{Number(score).toFixed(1)}</span>
+ <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}>
+ {Number(score).toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 80,
+ meta: {
+ excelHeader: "자율평가",
+ },
},
// ✅ 합계 - 4개 점수의 합으로 계산
@@ -502,9 +686,12 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
const selfEvaluationScore = Number(row.getValue("selfEvaluationScore") || 0);
const total = processScore + priceScore + deliveryScore + selfEvaluationScore;
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
return total > 0 ? (
- <span className="font-medium bg-blue-50 px-2 py-1 rounded">
+ <span className={`font-medium px-2 py-1 rounded ${
+ isAggregated ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'
+ }`}>
{total.toFixed(1)}
</span>
) : (
@@ -512,6 +699,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
);
},
size: 80,
+ meta: {
+ excelHeader: "합계",
+ },
},
{
@@ -519,13 +709,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여도(가점)" />,
cell: ({ row }) => {
const score = row.getValue("participationBonus");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return score ? (
- <span className="font-medium text-green-600">+{Number(score).toFixed(1)}</span>
+ <span className={`font-medium ${isAggregated ? 'text-purple-600' : 'text-green-600'}`}>
+ +{Number(score).toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 100,
+ meta: {
+ excelHeader: "참여도(가점)",
+ },
},
{
@@ -533,16 +730,23 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질(감점)" />,
cell: ({ row }) => {
const score = row.getValue("qualityDeduction");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return score ? (
- <span className="font-medium text-red-600">-{Number(score).toFixed(1)}</span>
+ <span className={`font-medium ${isAggregated ? 'text-purple-600' : 'text-red-600'}`}>
+ -{Number(score).toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 100,
+ meta: {
+ excelHeader: "품질(감점)",
+ },
},
- // ✅ 새로운 평가점수 컬럼 추가
+ // ✅ 평가점수 컬럼
{
id: "evaluationScore",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가점수" />,
@@ -556,9 +760,12 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
const totalScore = processScore + priceScore + deliveryScore + selfEvaluationScore;
const evaluationScore = totalScore + participationBonus - qualityDeduction;
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
return totalScore > 0 ? (
- <span className="font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">
+ <span className={`font-bold px-2 py-1 rounded ${
+ isAggregated ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'
+ }`}>
{evaluationScore.toFixed(1)}
</span>
) : (
@@ -566,6 +773,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
);
},
size: 90,
+ meta: {
+ excelHeader: "평가점수",
+ },
},
{
@@ -573,16 +783,27 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가등급" />,
cell: ({ row }) => {
const grade = row.getValue<string>("evaluationGrade");
+ const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1;
+
return grade ? (
- <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge>
+ <Badge
+ variant={getGradeBadgeVariant(grade)}
+ className={isAggregated ? "bg-purple-600" : ""}
+ >
+ {grade}
+ </Badge>
) : (
<span className="text-muted-foreground">-</span>
);
},
minSize: 100,
+ meta: {
+ excelHeader: "평가등급",
+ },
},
-
]
},
];
+
+ return baseColumns;
} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx
index 7f4de6a6..8f435e36 100644
--- a/lib/evaluation/table/evaluation-filter-sheet.tsx
+++ b/lib/evaluation/table/evaluation-filter-sheet.tsx
@@ -1,19 +1,15 @@
-// ================================================================
-// 2. PERIODIC EVALUATIONS FILTER SHEET
-// ================================================================
-
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
+"use client";
+
+import { useEffect, useTransition, useState, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Search, X } from "lucide-react";
+import { customAlphabet } from "nanoid";
+import { parseAsStringEnum, useQueryState } from "nuqs";
+
+import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
@@ -21,50 +17,28 @@ import {
FormItem,
FormLabel,
FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { getFiltersStateParser } from "@/lib/parsers"
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { getFiltersStateParser } from "@/lib/parsers";
+import { EVALUATION_TARGET_FILTER_OPTIONS } from "@/lib/evaluation-target-list/validation";
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+/*****************************************************************************************
+ * UTILS & CONSTANTS
+ *****************************************************************************************/
-// 정기평가 필터 스키마 정의
-const periodicEvaluationFilterSchema = z.object({
- evaluationYear: z.string().optional(),
- evaluationPeriod: z.string().optional(),
- division: z.string().optional(),
- status: z.string().optional(),
- domesticForeign: z.string().optional(),
- materialType: z.string().optional(),
- vendorCode: z.string().optional(),
- vendorName: z.string().optional(),
- documentsSubmitted: z.string().optional(),
- evaluationGrade: z.string().optional(),
- finalGrade: z.string().optional(),
- minTotalScore: z.string().optional(),
- maxTotalScore: z.string().optional(),
-})
+// nanoid generator (6‑chars [0-9a-zA-Z])
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6);
-// 옵션 정의
-const evaluationPeriodOptions = [
- { value: "상반기", label: "상반기" },
- { value: "하반기", label: "하반기" },
- { value: "연간", label: "연간" },
-]
-
-const divisionOptions = [
- { value: "PLANT", label: "해양" },
- { value: "SHIP", label: "조선" },
-]
+// ── SELECT OPTIONS ──────────────────────────────────────────────────────────────────────
const statusOptions = [
{ value: "PENDING", label: "대상확정" },
@@ -73,70 +47,76 @@ const statusOptions = [
{ value: "IN_REVIEW", label: "평가중" },
{ value: "REVIEW_COMPLETED", label: "평가완료" },
{ value: "FINALIZED", label: "결과확정" },
-]
-
-const domesticForeignOptions = [
- { value: "DOMESTIC", label: "내자" },
- { value: "FOREIGN", label: "외자" },
-]
+];
-const materialTypeOptions = [
- { value: "EQUIPMENT", label: "기자재" },
- { value: "BULK", label: "벌크" },
- { value: "EQUIPMENT_BULK", label: "기자재/벌크" },
-]
const documentsSubmittedOptions = [
{ value: "true", label: "제출완료" },
{ value: "false", label: "미제출" },
-]
+];
const gradeOptions = [
{ value: "A", label: "A등급" },
{ value: "B", label: "B등급" },
{ value: "C", label: "C등급" },
{ value: "D", label: "D등급" },
-]
+];
-type PeriodicEvaluationFilterFormValues = z.infer<typeof periodicEvaluationFilterSchema>
+/*****************************************************************************************
+ * ZOD SCHEMA & TYPES
+ *****************************************************************************************/
+const periodicEvaluationFilterSchema = z.object({
+ evaluationYear: z.string().optional(),
+ division: z.string().optional(),
+ status: z.string().optional(),
+ domesticForeign: z.string().optional(),
+ materialType: z.string().optional(),
+ vendorCode: z.string().optional(),
+ vendorName: z.string().optional(),
+ documentsSubmitted: z.string().optional(),
+ evaluationGrade: z.string().optional(),
+ finalGrade: z.string().optional(),
+ minTotalScore: z.string().optional(),
+ maxTotalScore: z.string().optional(),
+});
+export type PeriodicEvaluationFilterFormValues = z.infer<
+ typeof periodicEvaluationFilterSchema
+>;
+
+/*****************************************************************************************
+ * COMPONENT
+ *****************************************************************************************/
interface PeriodicEvaluationFilterSheetProps {
+ /** Slide‑over visibility */
isOpen: boolean;
+ /** Close panel handler */
onClose: () => void;
- onSearch?: () => void;
+ /** Show skeleton / spinner while outer data grid fetches */
isLoading?: boolean;
+ /** Optional: fire immediately after URL is patched so parent can refetch */
+ onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void; // ✅ 필터 전달 콜백
}
export function PeriodicEvaluationFilterSheet({
isOpen,
onClose,
- onSearch,
- isLoading = false
+ isLoading = false,
+ onFiltersApply,
}: PeriodicEvaluationFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
-
- const [isPending, startTransition] = useTransition()
- const [isInitializing, setIsInitializing] = useState(false)
- const lastAppliedFilters = useRef<string>("")
+ /** Router (needed only for pathname) */
+ const router = useRouter();
- // nuqs로 URL 상태 관리
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
+ /** Track pending state while we update URL */
+ const [isPending, startTransition] = useTransition();
+ const [joinOperator, setJoinOperator] = useState<"and" | "or">("and")
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
- // 폼 상태 초기화
+ /** React‑Hook‑Form */
const form = useForm<PeriodicEvaluationFilterFormValues>({
resolver: zodResolver(periodicEvaluationFilterSchema),
defaultValues: {
evaluationYear: new Date().getFullYear().toString(),
- evaluationPeriod: "",
division: "",
status: "",
domesticForeign: "",
@@ -149,273 +129,130 @@ export function PeriodicEvaluationFilterSheet({
minTotalScore: "",
maxTotalScore: "",
},
- })
-
- // URL 필터에서 초기 폼 상태 설정
- useEffect(() => {
- const currentFiltersString = JSON.stringify(filters);
-
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id in formValues) {
- // @ts-ignore - 동적 필드 접근
- formValues[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen])
+ });
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
- // 폼 제출 핸들러
+ /*****************************************************************************************
+ * 3️⃣ Submit → build filter array → push to URL (and reset page=1)
+ *****************************************************************************************/
async function onSubmit(data: PeriodicEvaluationFilterFormValues) {
- if (isInitializing) return;
-
- startTransition(async () => {
+ startTransition(() => {
try {
- const newFilters = []
+ const newFilters: any[] = [];
- if (data.evaluationYear?.trim()) {
- newFilters.push({
- id: "evaluationYear",
- value: parseInt(data.evaluationYear.trim()),
- type: "number",
- operator: "eq",
- rowId: generateId()
- })
- }
+ const pushFilter = (
+ id: string,
+ value: any,
+ type: "text" | "select" | "number" | "boolean",
+ operator: "eq" | "iLike" | "gte" | "lte"
+ ) => {
+ newFilters.push({ id, value, type, operator, rowId: generateId() });
+ };
- if (data.evaluationPeriod?.trim()) {
- newFilters.push({
- id: "evaluationPeriod",
- value: data.evaluationPeriod.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.evaluationYear?.trim())
+ pushFilter("evaluationYear", Number(data.evaluationYear), "number", "eq");
- if (data.division?.trim()) {
- newFilters.push({
- id: "division",
- value: data.division.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.division?.trim())
+ pushFilter("division", data.division.trim(), "select", "eq");
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.status?.trim())
+ pushFilter("status", data.status.trim(), "select", "eq");
- if (data.domesticForeign?.trim()) {
- newFilters.push({
- id: "domesticForeign",
- value: data.domesticForeign.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.domesticForeign?.trim())
+ pushFilter("domesticForeign", data.domesticForeign.trim(), "select", "eq");
- if (data.materialType?.trim()) {
- newFilters.push({
- id: "materialType",
- value: data.materialType.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.materialType?.trim())
+ pushFilter("materialType", data.materialType.trim(), "select", "eq");
- if (data.vendorCode?.trim()) {
- newFilters.push({
- id: "vendorCode",
- value: data.vendorCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
+ if (data.vendorCode?.trim())
+ pushFilter("vendorCode", data.vendorCode.trim(), "text", "iLike");
- if (data.vendorName?.trim()) {
- newFilters.push({
- id: "vendorName",
- value: data.vendorName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
+ if (data.vendorName?.trim())
+ pushFilter("vendorName", data.vendorName.trim(), "text", "iLike");
- if (data.documentsSubmitted?.trim()) {
- newFilters.push({
- id: "documentsSubmitted",
- value: data.documentsSubmitted.trim() === "true",
- type: "boolean",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.documentsSubmitted?.trim())
+ pushFilter(
+ "documentsSubmitted",
+ data.documentsSubmitted.trim() === "true",
+ "boolean",
+ "eq"
+ );
- if (data.evaluationGrade?.trim()) {
- newFilters.push({
- id: "evaluationGrade",
- value: data.evaluationGrade.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.evaluationGrade?.trim())
+ pushFilter("evaluationGrade", data.evaluationGrade.trim(), "select", "eq");
- if (data.finalGrade?.trim()) {
- newFilters.push({
- id: "finalGrade",
- value: data.finalGrade.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
+ if (data.finalGrade?.trim())
+ pushFilter("finalGrade", data.finalGrade.trim(), "select", "eq");
- if (data.minTotalScore?.trim()) {
- newFilters.push({
- id: "totalScore",
- value: parseFloat(data.minTotalScore.trim()),
- type: "number",
- operator: "gte",
- rowId: generateId()
- })
- }
+ if (data.minTotalScore?.trim())
+ pushFilter("totalScore", Number(data.minTotalScore), "number", "gte");
- if (data.maxTotalScore?.trim()) {
- newFilters.push({
- id: "totalScore",
- value: parseFloat(data.maxTotalScore.trim()),
- type: "number",
- operator: "lte",
- rowId: generateId()
- })
- }
+ if (data.maxTotalScore?.trim())
+ pushFilter("totalScore", Number(data.maxTotalScore), "number", "lte");
- // URL 업데이트
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- params.delete('basicFilters');
- params.delete('basicJoinOperator');
- params.delete('page');
-
- if (newFilters.length > 0) {
- params.set('basicFilters', JSON.stringify(newFilters));
- params.set('basicJoinOperator', joinOperator);
- }
-
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- window.location.href = newUrl;
+ setJoinOperator(joinOperator);
- lastAppliedFilters.current = JSON.stringify(newFilters);
- if (onSearch) {
- onSearch();
- }
- } catch (error) {
- console.error("정기평가 필터 적용 오류:", error);
+ onFiltersApply(newFilters, joinOperator);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error("정기평가 필터 적용 오류:", err);
}
- })
+ });
}
- // 필터 초기화 핸들러
+ /*****************************************************************************************
+ * 4️⃣ Reset → clear form & URL
+ *****************************************************************************************/
async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- evaluationYear: new Date().getFullYear().toString(),
- evaluationPeriod: "",
- division: "",
- status: "",
- domesticForeign: "",
- materialType: "",
- vendorCode: "",
- vendorName: "",
- documentsSubmitted: "",
- evaluationGrade: "",
- finalGrade: "",
- minTotalScore: "",
- maxTotalScore: "",
- });
-
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- params.delete('basicFilters');
- params.delete('basicJoinOperator');
- params.set('page', '1');
+ form.reset({
+ evaluationYear: new Date().getFullYear().toString(),
+ division: "",
+ status: "",
+ domesticForeign: "",
+ materialType: "",
+ vendorCode: "",
+ vendorName: "",
+ documentsSubmitted: "",
+ evaluationGrade: "",
+ finalGrade: "",
+ minTotalScore: "",
+ maxTotalScore: "",
+ });
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- window.location.href = newUrl;
+ onFiltersApply([], "and");
+ setJoinOperator("and");
- lastAppliedFilters.current = "";
- setIsInitializing(false);
- } catch (error) {
- console.error("정기평가 필터 초기화 오류:", error);
- setIsInitializing(false);
- }
}
- if (!isOpen) {
- return null;
- }
+ /*****************************************************************************************
+ * 5️⃣ RENDER
+ *****************************************************************************************/
+ if (!isOpen) return null;
return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* Filter Panel Header */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">정기평가 검색 필터</h3>
+ <div
+ className="flex h-full max-h-full flex-col px-6 sm:px-8"
+ style={{ backgroundColor: "#F5F7FB", paddingLeft: "2rem", paddingRight: "2rem" }}
+ >
+ {/* Header */}
+ <div className="flex shrink-0 min-h-[60px] items-center justify-between px-6">
+ <h3 className="whitespace-nowrap text-lg font-semibold">정기평가 검색 필터</h3>
<div className="flex items-center gap-2">
- {getActiveFilterCount() > 0 && (
- <Badge variant="secondary" className="px-2 py-1">
- {getActiveFilterCount()}개 필터 적용됨
- </Badge>
- )}
+ <Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
+ <X className="size-4" />
+ </Button>
</div>
</div>
- {/* Join Operator Selection */}
- <div className="px-6 shrink-0">
+ {/* Join‑operator selector */}
+ <div className="shrink-0 px-6">
<label className="text-sm font-medium">조건 결합 방식</label>
<Select
value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
+ onValueChange={(v: "and" | "or") => setJoinOperator(v)}
>
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectTrigger className="mt-2 h-8 w-[180px] bg-white">
<SelectValue placeholder="조건 결합 방식" />
</SelectTrigger>
<SelectContent>
@@ -425,12 +262,12 @@ export function PeriodicEvaluationFilterSheet({
</Select>
</div>
+ {/* Form */}
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area */}
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-col h-full">
+ {/* Scrollable area */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
<div className="space-y-4 pt-2">
-
{/* 평가년도 */}
<FormField
control={form.control}
@@ -444,20 +281,20 @@ export function PeriodicEvaluationFilterSheet({
type="number"
placeholder="평가년도 입력"
{...field}
- className={cn(field.value && "pr-8", "bg-white")}
disabled={isInitializing}
+ className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="absolute right-0 top-0 h-full px-2"
onClick={(e) => {
e.stopPropagation();
form.setValue("evaluationYear", "");
}}
disabled={isInitializing}
+ className="absolute right-0 top-0 h-full px-2"
>
<X className="size-3.5" />
</Button>
@@ -469,52 +306,6 @@ export function PeriodicEvaluationFilterSheet({
)}
/>
- {/* 평가기간 */}
- {/* <FormField
- control={form.control}
- name="evaluationPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>평가기간</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="평가기간 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("evaluationPeriod", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {evaluationPeriodOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- /> */}
{/* 구분 */}
<FormField
@@ -530,14 +321,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="구분 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("division", "");
@@ -551,9 +342,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {divisionOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {EVALUATION_TARGET_FILTER_OPTIONS.DIVISIONS.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -577,14 +368,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="진행상태 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("status", "");
@@ -598,9 +389,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {statusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {statusOptions.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -624,14 +415,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="내외자 구분 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("domesticForeign", "");
@@ -645,9 +436,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {domesticForeignOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {EVALUATION_TARGET_FILTER_OPTIONS.DOMESTIC_FOREIGN.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -671,14 +462,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="자재구분 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("materialType", "");
@@ -692,9 +483,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {materialTypeOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {EVALUATION_TARGET_FILTER_OPTIONS.MATERIAL_TYPES.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -716,8 +507,8 @@ export function PeriodicEvaluationFilterSheet({
<Input
placeholder="벤더 코드 입력"
{...field}
- className={cn(field.value && "pr-8", "bg-white")}
disabled={isInitializing}
+ className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
<Button
@@ -753,8 +544,8 @@ export function PeriodicEvaluationFilterSheet({
<Input
placeholder="벤더명 입력"
{...field}
- className={cn(field.value && "pr-8", "bg-white")}
disabled={isInitializing}
+ className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
<Button
@@ -792,14 +583,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="문서제출여부 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("documentsSubmitted", "");
@@ -813,9 +604,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {documentsSubmittedOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {documentsSubmittedOptions.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -839,14 +630,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="평가등급 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("evaluationGrade", "");
@@ -860,9 +651,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {gradeOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {gradeOptions.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -886,14 +677,14 @@ export function PeriodicEvaluationFilterSheet({
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
+ <div className="flex w-full justify-between">
<SelectValue placeholder="최종등급 선택" />
{field.value && (
<Button
type="button"
variant="ghost"
size="icon"
- className="h-4 w-4 -mr-2"
+ className="-mr-2 h-4 w-4"
onClick={(e) => {
e.stopPropagation();
form.setValue("finalGrade", "");
@@ -907,9 +698,9 @@ export function PeriodicEvaluationFilterSheet({
</SelectTrigger>
</FormControl>
<SelectContent>
- {gradeOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
+ {gradeOptions.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
</SelectItem>
))}
</SelectContent>
@@ -934,8 +725,8 @@ export function PeriodicEvaluationFilterSheet({
step="0.1"
placeholder="최소"
{...field}
- className={cn(field.value && "pr-8", "bg-white")}
disabled={isInitializing}
+ className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
<Button
@@ -972,8 +763,8 @@ export function PeriodicEvaluationFilterSheet({
step="0.1"
placeholder="최대"
{...field}
- className={cn(field.value && "pr-8", "bg-white")}
disabled={isInitializing}
+ className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
<Button
@@ -997,18 +788,17 @@ export function PeriodicEvaluationFilterSheet({
)}
/>
</div>
-
</div>
</div>
- {/* Fixed buttons at bottom */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
+ {/* Footer buttons */}
+ <div className="shrink-0 p-4">
+ <div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ disabled={isPending }
className="px-4"
>
초기화
@@ -1016,10 +806,10 @@ export function PeriodicEvaluationFilterSheet({
<Button
type="submit"
variant="samsung"
- disabled={isPending || isLoading || isInitializing}
+ disabled={isPending || isLoading }
className="px-4"
>
- <Search className="size-4 mr-2" />
+ <Search className="mr-2 size-4" />
{isPending || isLoading ? "조회 중..." : "조회"}
</Button>
</div>
@@ -1027,5 +817,5 @@ export function PeriodicEvaluationFilterSheet({
</form>
</Form>
</div>
- )
-} \ No newline at end of file
+ );
+}
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index d4510eb5..257225c8 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -1,12 +1,21 @@
+// lib/evaluation/table/evaluation-table.tsx - 최종 정리된 버전
+
"use client"
import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import { PanelLeftClose, PanelLeftOpen, BarChart3, List, Info } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
@@ -19,22 +28,106 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv
import { cn } from "@/lib/utils"
import { useTablePresets } from "@/components/data-table/use-table-presets"
import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { useMemo } from "react"
import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
-import { PeriodicEvaluationView } from "@/db/schema"
-import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service"
+import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema"
+import {
+ getPeriodicEvaluationsWithAggregation,
+ getPeriodicEvaluationsStats
+} from "../service"
import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions"
import { EvaluationDetailsDialog } from "./evaluation-details-dialog"
+import { searchParamsEvaluationsCache } from "../validation"
interface PeriodicEvaluationsTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]>
+ promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluationsWithAggregation>>]>
evaluationYear: number
+ initialViewMode?: "detailed" | "aggregated" // ✅ 페이지에서 전달받는 초기 모드
className?: string
}
+// 뷰 모드 토글 컴포넌트
+function EvaluationViewToggle({
+ value,
+ onValueChange,
+ detailedCount,
+ aggregatedCount,
+}: {
+ value: "detailed" | "aggregated";
+ onValueChange: (value: "detailed" | "aggregated") => void;
+ detailedCount?: number;
+ aggregatedCount?: number;
+}) {
+ return (
+ <div className="flex items-center gap-2">
+ <ToggleGroup
+ type="single"
+ value={value}
+ onValueChange={(newValue) => {
+ if (newValue) onValueChange(newValue as "detailed" | "aggregated");
+ }}
+ className="bg-muted p-1 rounded-lg"
+ >
+ <ToggleGroupItem
+ value="detailed"
+ aria-label="상세 뷰"
+ className="flex items-center gap-2 data-[state=on]:bg-background"
+ >
+ <List className="h-4 w-4" />
+ <span>상세 뷰</span>
+ {detailedCount !== undefined && (
+ <Badge variant="secondary" className="ml-1 text-xs">
+ {detailedCount}
+ </Badge>
+ )}
+ </ToggleGroupItem>
+
+ <ToggleGroupItem
+ value="aggregated"
+ aria-label="집계 뷰"
+ className="flex items-center gap-2 data-[state=on]:bg-background"
+ >
+ <BarChart3 className="h-4 w-4" />
+ <span>집계 뷰</span>
+ {aggregatedCount !== undefined && (
+ <Badge variant="secondary" className="ml-1 text-xs">
+ {aggregatedCount}
+ </Badge>
+ )}
+ </ToggleGroupItem>
+ </ToggleGroup>
+
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="ghost" size="icon" className="h-8 w-8">
+ <Info className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="bottom" className="max-w-sm">
+ <div className="space-y-2 text-sm">
+ <div>
+ <strong>상세 뷰:</strong> 모든 평가 기록을 개별적으로 표시
+ </div>
+ <div>
+ <strong>집계 뷰:</strong> 동일 벤더의 여러 division 평가를 평균으로 통합하여 표시
+ </div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+}
+
// 통계 카드 컴포넌트
-function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }) {
+function PeriodicEvaluationsStats({
+ evaluationYear,
+ viewMode
+}: {
+ evaluationYear: number;
+ viewMode: "detailed" | "aggregated";
+}) {
const [stats, setStats] = React.useState<any>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
@@ -47,8 +140,11 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
setIsLoading(true)
setError(null)
- // 실제 통계 함수 호출
- const statsData = await getPeriodicEvaluationsStats(evaluationYear)
+ // 뷰 모드에 따라 다른 통계 함수 호출
+ const statsData = await getPeriodicEvaluationsStats(
+ evaluationYear,
+ viewMode === "aggregated"
+ )
if (isMounted) {
setStats(statsData)
@@ -70,7 +166,7 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
return () => {
isMounted = false
}
- }, [evaluationYear]) // evaluationYear 의존성 추가
+ }, [evaluationYear, viewMode])
if (isLoading) {
return (
@@ -114,13 +210,25 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
{/* 총 평가 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">총 평가</CardTitle>
- <Badge variant="outline">{evaluationYear}년</Badge>
+ <CardTitle className="text-sm font-medium">
+ 총 {viewMode === "aggregated" ? "벤더" : "평가"}
+ </CardTitle>
+ <div className="flex items-center gap-1">
+ <Badge variant="outline">{evaluationYear}년</Badge>
+ {viewMode === "aggregated" && (
+ <Badge variant="secondary" className="text-xs">집계</Badge>
+ )}
+ </div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalEvaluations.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
평균점수 {stats.averageScore?.toFixed(1) || 0}점
+ {viewMode === "aggregated" && stats.totalEvaluationCount && (
+ <span className="ml-2">
+ (총 {stats.totalEvaluationCount}개 평가)
+ </span>
+ )}
</div>
</CardContent>
</Card>
@@ -172,12 +280,55 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
)
}
-export function PeriodicEvaluationsTable({ promises, evaluationYear, className }: PeriodicEvaluationsTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView> | null>(null)
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+export function PeriodicEvaluationsTable({
+ promises,
+ evaluationYear,
+ initialViewMode = "detailed",
+ className
+}: PeriodicEvaluationsTableProps) {
const router = useRouter()
const searchParams = useSearchParams()
-
+
+ // ✅ URL에서 현재 집계 모드 상태 읽기
+ const currentParams = searchParamsEvaluationsCache.parse(Object.fromEntries(searchParams.entries()))
+ const [viewMode, setViewMode] = React.useState<"detailed" | "aggregated">(
+ currentParams.aggregated ? "aggregated" : "detailed"
+ )
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> | null>(null)
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+ const [detailedCount, setDetailedCount] = React.useState<number | undefined>(undefined)
+ const [aggregatedCount, setAggregatedCount] = React.useState<number | undefined>(undefined)
+
+ const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
+ const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
+
+ // ✅ 뷰 모드 변경 시 URL 업데이트
+ const handleViewModeChange = React.useCallback((newMode: "detailed" | "aggregated") => {
+ setViewMode(newMode);
+
+ // URL 파라미터 업데이트
+ const newSearchParams = new URLSearchParams(searchParams.toString())
+ if (newMode === "aggregated") {
+ newSearchParams.set("aggregated", "true")
+ } else {
+ newSearchParams.delete("aggregated")
+ }
+
+ // 페이지를 1로 리셋
+ newSearchParams.set("page", "1")
+
+ router.push(`?${newSearchParams.toString()}`, { scroll: false })
+ }, [router, searchParams])
+
+ const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
+ console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
+ setExternalFilters(filters);
+ setExternalJoinOperator(joinOperator);
+ setIsFilterPanelOpen(false);
+ }, []);
+
+ // 컨테이너 위치 추적
const containerRef = React.useRef<HTMLDivElement>(null)
const [containerTop, setContainerTop] = React.useState(0)
@@ -185,7 +336,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
const newTop = rect.top
-
setContainerTop(prevTop => {
if (Math.abs(prevTop - newTop) > 1) {
return newTop
@@ -195,69 +345,44 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
}
}, [])
- const throttledUpdateBounds = React.useCallback(() => {
- let timeoutId: NodeJS.Timeout
- return () => {
- clearTimeout(timeoutId)
- timeoutId = setTimeout(updateContainerBounds, 16)
- }
- }, [updateContainerBounds])
-
React.useEffect(() => {
updateContainerBounds()
-
- const throttledHandler = throttledUpdateBounds()
-
- const handleResize = () => {
- updateContainerBounds()
+ const throttledHandler = () => {
+ let timeoutId: NodeJS.Timeout
+ return () => {
+ clearTimeout(timeoutId)
+ timeoutId = setTimeout(updateContainerBounds, 16)
+ }
}
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', throttledHandler)
+ const handler = throttledHandler()
+ window.addEventListener('resize', updateContainerBounds)
+ window.addEventListener('scroll', handler)
return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', throttledHandler)
+ window.removeEventListener('resize', updateContainerBounds)
+ window.removeEventListener('scroll', handler)
}
- }, [updateContainerBounds, throttledUpdateBounds])
+ }, [updateContainerBounds])
+ // 데이터 로드
const [promiseData] = React.use(promises)
const tableData = promiseData
-
- const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
- return searchParams?.get(key) ?? defaultValue ?? "";
- }, [searchParams]);
-
- const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
- try {
- const value = getSearchParam(key);
- return value ? JSON.parse(value) : defaultValue;
- } catch {
- return defaultValue;
- }
- }, [getSearchParam]);
-
- const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
- return parseSearchParamHelper(key, defaultValue);
- };
-
+ // 테이블 설정
const initialSettings = React.useMemo(() => ({
- page: parseInt(getSearchParam('page') || '1'),
- perPage: parseInt(getSearchParam('perPage') || '10'),
- sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
- filters: getSearchParam('filters') ? JSON.parse(getSearchParam('filters')!) : [],
- joinOperator: (getSearchParam('joinOperator') as "and" | "or") || "and",
- basicFilters: getSearchParam('basicFilters') ?
- JSON.parse(getSearchParam('basicFilters')!) : [],
- basicJoinOperator: (getSearchParam('basicJoinOperator') as "and" | "or") || "and",
- search: getSearchParam('search') || '',
+ page: currentParams.page || 1,
+ perPage: currentParams.perPage || 10,
+ sort: currentParams.sort || [{ id: "createdAt", desc: true }],
+ filters: currentParams.filters || [],
+ joinOperator: currentParams.joinOperator || "and",
+ search: "",
columnVisibility: {},
columnOrder: [],
pinnedColumns: { left: [], right: ["actions"] },
groupBy: [],
expandedRows: []
- }), [searchParams])
+ }), [currentParams])
const {
presets,
@@ -270,28 +395,31 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
deletePreset,
setDefaultPreset,
renamePreset,
- updateClientState,
getCurrentSettings,
- } = useTablePresets<PeriodicEvaluationView>('periodic-evaluations-table', initialSettings)
+ } = useTablePresets<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>('periodic-evaluations-table', initialSettings)
- const columns = React.useMemo(
- () => getPeriodicEvaluationsColumns({ setRowAction }),
- [setRowAction]
- )
+ // 집계 모드에 따라 컬럼 수정
+ const columns = React.useMemo(() => {
+ return getPeriodicEvaluationsColumns({
+ setRowAction,
+ viewMode
+ });
+ }, [viewMode, setRowAction]);
- const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [
+ const filterFields: DataTableFilterField<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [
{ id: "vendorCode", label: "벤더 코드" },
{ id: "vendorName", label: "벤더명" },
{ id: "status", label: "진행상태" },
]
- const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [
+ const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [
{ id: "evaluationYear", label: "평가년도", type: "number" },
{ id: "evaluationPeriod", label: "평가기간", type: "text" },
{
id: "division", label: "구분", type: "select", options: [
{ label: "해양", value: "PLANT" },
{ label: "조선", value: "SHIP" },
+ ...(viewMode === "aggregated" ? [{ label: "통합", value: "BOTH" }] : []),
]
},
{ id: "vendorCode", label: "벤더 코드", type: "text" },
@@ -311,7 +439,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
{ label: "미제출", value: "false" },
]
},
- { id: "totalScore", label: "총점", type: "number" },
{ id: "finalScore", label: "최종점수", type: "number" },
{ id: "submissionDate", label: "제출일", type: "date" },
{ id: "reviewCompletedAt", label: "검토완료일", type: "date" },
@@ -326,7 +453,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
columnPinning: currentSettings.pinnedColumns,
}), [columns, currentSettings, initialSettings.sort]);
-
const { table } = useDataTable({
data: tableData.data,
columns,
@@ -341,18 +467,13 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
clearOnDefault: true,
})
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- const getActiveBasicFilterCount = () => {
+ const getActiveFilterCount = React.useCallback(() => {
try {
- const basicFilters = getSearchParam('basicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
+ return currentParams.filters?.length || 0;
+ } catch {
+ return 0;
}
- }
+ }, [currentParams.filters]);
const FILTER_PANEL_WIDTH = 400;
@@ -374,7 +495,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
<PeriodicEvaluationFilterSheet
isOpen={isFilterPanelOpen}
onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
+ onFiltersApply={handleFiltersApply}
isLoading={false}
/>
</div>
@@ -393,35 +514,56 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
}}
>
- {/* Header Bar */}
+ {/* Header Bar with View Toggle */}
<div className="flex items-center justify-between p-4 bg-background shrink-0">
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
- type='button'
+ type="button"
onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
className="flex items-center shadow-sm"
>
{isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
- {getActiveBasicFilterCount() > 0 && (
+ {getActiveFilterCount() > 0 && (
<span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
+ {getActiveFilterCount()}
</span>
)}
</Button>
+
+ {/* ✅ 뷰 모드 토글 */}
+ <EvaluationViewToggle
+ value={viewMode}
+ onValueChange={handleViewModeChange}
+ detailedCount={detailedCount}
+ aggregatedCount={aggregatedCount}
+ />
</div>
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || tableData.data.length}건</span>
- )}
+ <div className="flex items-center gap-4">
+ <div className="text-sm text-muted-foreground">
+ {viewMode === "detailed" ? (
+ <span>모든 평가 기록 표시</span>
+ ) : (
+ <span>벤더별 통합 평가 표시</span>
+ )}
+ </div>
+
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || tableData.data.length}건</span>
+ )}
+ </div>
</div>
</div>
{/* 통계 카드들 */}
<div className="px-4">
- <PeriodicEvaluationsStats evaluationYear={evaluationYear} />
+ <PeriodicEvaluationsStats
+ evaluationYear={evaluationYear}
+ viewMode={viewMode}
+ />
</div>
{/* Table Content Area */}
@@ -431,10 +573,16 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
+ debounceMs={300}
shallow={false}
+ externalFilters={externalFilters}
+ externalJoinOperator={externalJoinOperator}
+ onFiltersChange={(filters, joinOperator) => {
+ console.log("=== 필터 변경 감지 ===", filters, joinOperator);
+ }}
>
<div className="flex items-center gap-2">
- <TablePresetManager<PeriodicEvaluationView>
+ <TablePresetManager<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>
presets={presets}
activePresetId={activePresetId}
currentSettings={currentSettings}
@@ -448,9 +596,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
onRenamePreset={renamePreset}
/>
- <PeriodicEvaluationsTableToolbarActions
- table={table}
- />
+ <PeriodicEvaluationsTableToolbarActions table={table} />
</div>
</DataTableAdvancedToolbar>
</DataTable>
@@ -459,12 +605,11 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
open={rowAction?.type === "view"}
onOpenChange={(open) => {
if (!open) {
- setRowAction(null)
+ setRowAction(null);
}
}}
evaluation={rowAction?.row.original || null}
/>
-
</div>
</div>
</div>
diff --git a/lib/evaluation/table/evaluation-view-toggle.tsx b/lib/evaluation/table/evaluation-view-toggle.tsx
new file mode 100644
index 00000000..e4fed6a8
--- /dev/null
+++ b/lib/evaluation/table/evaluation-view-toggle.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { Info, BarChart3, List } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+interface EvaluationViewToggleProps {
+ value: "detailed" | "aggregated";
+ onValueChange: (value: "detailed" | "aggregated") => void;
+ detailedCount?: number;
+ aggregatedCount?: number;
+}
+
+export function EvaluationViewToggle({
+ value,
+ onValueChange,
+ detailedCount,
+ aggregatedCount,
+}: EvaluationViewToggleProps) {
+ return (
+ <div className="flex items-center gap-2">
+ <ToggleGroup
+ type="single"
+ value={value}
+ onValueChange={(newValue) => {
+ if (newValue) onValueChange(newValue as "detailed" | "aggregated");
+ }}
+ className="bg-muted p-1 rounded-lg"
+ >
+ <ToggleGroupItem
+ value="detailed"
+ aria-label="상세 뷰"
+ className="flex items-center gap-2 data-[state=on]:bg-background"
+ >
+ <List className="h-4 w-4" />
+ <span>상세 뷰</span>
+ {detailedCount !== undefined && (
+ <Badge variant="secondary" className="ml-1">
+ {detailedCount}
+ </Badge>
+ )}
+ </ToggleGroupItem>
+
+ <ToggleGroupItem
+ value="aggregated"
+ aria-label="집계 뷰"
+ className="flex items-center gap-2 data-[state=on]:bg-background"
+ >
+ <BarChart3 className="h-4 w-4" />
+ <span>집계 뷰</span>
+ {aggregatedCount !== undefined && (
+ <Badge variant="secondary" className="ml-1">
+ {aggregatedCount}
+ </Badge>
+ )}
+ </ToggleGroupItem>
+ </ToggleGroup>
+
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="ghost" size="icon" className="h-8 w-8">
+ <Info className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="bottom" className="max-w-sm">
+ <div className="space-y-2 text-sm">
+ <div>
+ <strong>상세 뷰:</strong> 모든 평가 기록을 개별적으로 표시
+ </div>
+ <div>
+ <strong>집계 뷰:</strong> 동일 벤더의 여러 division 평가를 평균으로 통합하여 표시
+ </div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/evaluation/validation.ts b/lib/evaluation/validation.ts
index 9179f585..f1ed534c 100644
--- a/lib/evaluation/validation.ts
+++ b/lib/evaluation/validation.ts
@@ -3,7 +3,7 @@ import {
parseAsArrayOf,
parseAsInteger,
parseAsString,
- parseAsStringEnum,
+ parseAsStringEnum,parseAsBoolean
} from "nuqs/server";
import * as z from "zod";
@@ -30,13 +30,10 @@ import { periodicEvaluations } from "@/db/schema";
// 고급 필터
filters: getFiltersStateParser().withDefault([]),
joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 베이직 필터 (커스텀 필터 패널용)
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색
- search: parseAsString.withDefault(""),
+
+
+ aggregated: parseAsBoolean.withDefault(false),
+
});
// ============= 타입 정의 =============