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