diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-24 11:06:32 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-24 11:06:32 +0000 |
| commit | 1dc24d48e52f2e490f5603ceb02842586ecae533 (patch) | |
| tree | 8fca2c5b5b52cc10557b5ba6e55b937ae3c57cf6 /lib/evaluation | |
| parent | ed0d6fcc98f671280c2ccde797b50693da88152e (diff) | |
(대표님) 정기평가 피드백 반영, 설계 피드백 반영, (최겸) 기술영업 피드백 반영
Diffstat (limited to 'lib/evaluation')
| -rw-r--r-- | lib/evaluation/service.ts | 214 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-columns.tsx | 455 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-filter-sheet.tsx | 616 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 341 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-view-toggle.tsx | 88 | ||||
| -rw-r--r-- | lib/evaluation/validation.ts | 13 |
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), + }); // ============= 타입 정의 ============= |
