diff options
Diffstat (limited to 'lib/evaluation/table')
| -rw-r--r-- | lib/evaluation/table/evaluation-columns.tsx | 341 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 24 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluation-action-dialogs.tsx | 373 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx | 218 |
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 |
