diff options
Diffstat (limited to 'lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx')
| -rw-r--r-- | lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx | 278 |
1 files changed, 191 insertions, 87 deletions
diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 714f96c3..66a61912 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -1,104 +1,112 @@ -"use client" +'use client'; -import * as React from "react" -import { type Table } from "@tanstack/react-table" +/* IMPORT */ +import { + autoGenerateEvaluationTargets, + generateEvalTargetTemplate, + importEvalTargetExcel +} from '../service'; +import { Button } from '@/components/ui/button'; +import { + ConfirmTargetsDialog, + ExcludeTargetsDialog, + RequestReviewDialog, +} from './evaluation-target-action-dialogs'; import { - Plus, Check, - MessageSquare, - X, Download, - Upload, + FileInput, + LoaderCircle, + MessageSquare, + Plus, RefreshCw, - Settings, - Trash2 -} from "lucide-react" -import { toast } from "sonner" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" - -import { Button } from "@/components/ui/button" + Trash2, + Upload, + X, +} from 'lucide-react'; +import { DeleteTargetsDialog } from './delete-targets-dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { ManualCreateEvaluationTargetDialog } from "./manual-create-evaluation-target-dialog" -import { - ConfirmTargetsDialog, - ExcludeTargetsDialog, - RequestReviewDialog -} from "./evaluation-target-action-dialogs" -import { DeleteTargetsDialog } from "./delete-targets-dialog" -import { EvaluationTargetWithDepartments } from "@/db/schema" -import { exportTableToExcel } from "@/lib/export" -import { autoGenerateEvaluationTargets } from "../service" // 서버 액션 import -import { useAuthRole } from "@/hooks/use-auth-role" - +} from '@/components/ui/dropdown-menu'; +import { exportTableToExcel } from '@/lib/export'; +import { ManualCreateEvaluationTargetDialog } from './manual-create-evaluation-target-dialog'; +import { toast } from 'sonner'; +import { type ChangeEvent, useCallback, useMemo, useRef, useState } from 'react'; +import { type EvaluationTargetWithDepartments } from '@/db/schema'; +import { type Table } from '@tanstack/react-table'; +import { useAuthRole } from '@/hooks/use-auth-role'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ interface EvaluationTargetsTableToolbarActionsProps { - table: Table<EvaluationTargetWithDepartments> - onRefresh?: () => void + table: Table<EvaluationTargetWithDepartments>; + onRefresh?: () => void; } -export function EvaluationTargetsTableToolbarActions({ - table, - onRefresh -}: EvaluationTargetsTableToolbarActionsProps) { - const [isLoading, setIsLoading] = React.useState(false) - const [manualCreateDialogOpen, setManualCreateDialogOpen] = React.useState(false) - const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) - const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false) - const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const router = useRouter() - const { data: session } = useSession() +// ---------------------------------------------------------------------------------------------------- + +export function EvaluationTargetsTableToolbarActions(props: EvaluationTargetsTableToolbarActionsProps) { + const { table, onRefresh } = props; + const [isLoading, setIsLoading] = useState(false); + const [manualCreateDialogOpen, setManualCreateDialogOpen] = useState(false); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [excludeDialogOpen, setExcludeDialogOpen] = useState(false); + const [reviewDialogOpen, setReviewDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const router = useRouter(); + const { data: session } = useSession(); + const fileInputRef = useRef<HTMLInputElement>(null); // 권한 체크 - const { hasRole, isLoading: roleLoading } = useAuthRole() - const canManageEvaluations = hasRole('정기평가') || hasRole('admin') + const { hasRole, isLoading: roleLoading } = useAuthRole(); + const canManageEvaluations = hasRole('정기평가') || hasRole('admin'); // 사용자 ID 가져오기 - const userId = React.useMemo(() => { + const userId = useMemo(() => { return session?.user?.id ? Number(session.user.id) : 1; }, [session]); // 선택된 행들 - const selectedRows = table.getFilteredSelectedRowModel().rows - const hasSelection = selectedRows.length > 0 + const selectedRows = table.getFilteredSelectedRowModel().rows; + const hasSelection = selectedRows.length > 0; // ✅ selectedTargets를 useMemo로 안정화 (VendorsTable 방식과 동일) - const selectedTargets = React.useMemo(() => { + const selectedTargets = useMemo(() => { return selectedRows.map(row => row.original) - }, [selectedRows]) + }, [selectedRows]); // ✅ 각 상태별 타겟들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) - const pendingTargets = React.useMemo(() => { + const pendingTargets = useMemo(() => { return table .getFilteredSelectedRowModel() .rows .map(row => row.original) - .filter(t => t.status === "PENDING"); + .filter(t => t.status === 'PENDING'); }, [table.getFilteredSelectedRowModel().rows]); - const confirmedTargets = React.useMemo(() => { + const confirmedTargets = useMemo(() => { return table .getFilteredSelectedRowModel() .rows .map(row => row.original) - .filter(t => t.status === "CONFIRMED"); + .filter(t => t.status === 'CONFIRMED'); }, [table.getFilteredSelectedRowModel().rows]); - const excludedTargets = React.useMemo(() => { + const excludedTargets = useMemo(() => { return table .getFilteredSelectedRowModel() .rows .map(row => row.original) - .filter(t => t.status === "EXCLUDED"); + .filter(t => t.status === 'EXCLUDED'); }, [table.getFilteredSelectedRowModel().rows]); - const consensusTrueTargets = React.useMemo(() => { + const consensusTrueTargets = useMemo(() => { return table .getFilteredSelectedRowModel() .rows @@ -106,7 +114,7 @@ export function EvaluationTargetsTableToolbarActions({ .filter(t => t.consensusStatus === true); }, [table.getFilteredSelectedRowModel().rows]); - const consensusFalseTargets = React.useMemo(() => { + const consensusFalseTargets = useMemo(() => { return table .getFilteredSelectedRowModel() .rows @@ -114,7 +122,7 @@ export function EvaluationTargetsTableToolbarActions({ .filter(t => t.consensusStatus === false); }, [table.getFilteredSelectedRowModel().rows]); - const consensusNullTargets = React.useMemo(() => { + const consensusNullTargets = useMemo(() => { return table .getFilteredSelectedRowModel() .rows @@ -123,7 +131,7 @@ export function EvaluationTargetsTableToolbarActions({ }, [table.getFilteredSelectedRowModel().rows]); // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 - const selectedStats = React.useMemo(() => { + const selectedStats = useMemo(() => { const pending = pendingTargets.length const confirmed = confirmedTargets.length const excluded = excludedTargets.length @@ -155,12 +163,12 @@ export function EvaluationTargetsTableToolbarActions({ // ---------------------------------------------------------------- // 신규 평가 대상 생성 (자동) // ---------------------------------------------------------------- - const handleAutoGenerate = React.useCallback(async () => { - setIsLoading(true) + const handleAutoGenerate = useCallback(async () => { + setIsLoading(true); try { // 현재 년도를 기준으로 평가 대상 자동 생성 - const currentYear = new Date().getFullYear() - const result = await autoGenerateEvaluationTargets(currentYear, userId) + const currentYear = new Date().getFullYear(); + const result = await autoGenerateEvaluationTargets(currentYear, userId); if (result.success) { if (result.generatedCount === 0) { @@ -168,52 +176,112 @@ export function EvaluationTargetsTableToolbarActions({ description: result.skippedCount ? `이미 존재하는 평가 대상: ${result.skippedCount}개` : undefined - }) + }); } else { toast.success(result.message, { description: result.details ? `해양: ${result.details.shipTargets}개, 조선: ${result.details.plantTargets}개 생성${result.details.duplicateSkipped > 0 ? `, 중복 건너뜀: ${result.details.duplicateSkipped}개` : ''}` : undefined - }) + }); } - onRefresh?.() - router.refresh() + onRefresh?.(); + router.refresh(); } else { - toast.error(result.error || "자동 생성 중 오류가 발생했습니다.") + toast.error(result.error || '자동 생성 중 오류가 발생했습니다.'); } } catch (error) { - console.error('Error auto generating targets:', error) - toast.error("자동 생성 중 오류가 발생했습니다.") + console.error('Error auto generating targets:', error); + toast.error("자동 생성 중 오류가 발생했습니다."); } finally { - setIsLoading(false) + setIsLoading(false); } - }, [router, onRefresh, userId]) + }, [router, onRefresh, userId]); // ---------------------------------------------------------------- // 신규 평가 대상 생성 (수동) // ---------------------------------------------------------------- - const handleManualCreate = React.useCallback(() => { + const handleManualCreate = useCallback(() => { setManualCreateDialogOpen(true) - }, []) + }, []); // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = React.useCallback(() => { + const handleActionSuccess = useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - }, [table, onRefresh, router]) + }, [table, onRefresh, router]); + + + // EXCEL IMPORT + const handleImport = useCallback(() => { + fileInputRef.current?.click(); + }, [table]); + async function onFileChange(event: ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0]; + if (!file) { + toast.error('가져올 파일을 선택해주십시오.'); + return; + } + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('.xlsx 또는 .xls 확장자인 Excel 파일만 업로드 가능합니다.'); + return; + } + event.target.value = ''; - // ---------------------------------------------------------------- - // 내보내기 핸들러 - // ---------------------------------------------------------------- - const handleExport = React.useCallback(() => { + try { + const { errorFile, errorMessage, successMessage } = await importEvalTargetExcel(file); + + if (errorMessage) { + toast.error(errorMessage); + + if (errorFile) { + const url = URL.createObjectURL(errorFile); + const link = document.createElement('a'); + link.href = url; + link.download = 'errors.xlsx'; + link.click(); + URL.revokeObjectURL(url); + } + } else { + toast.success(successMessage || 'Excel 파일이 성공적으로 업로드되었습니다.'); + } + } catch (error) { + toast.error('Excel 파일을 업로드하는 중 오류가 발생했습니다.'); + console.error('Error in Excel File Upload: ', error); + } finally { + onRefresh?.(); + } + }; + + // EXCEL EXPORT + const handleExport = useCallback(() => { exportTableToExcel(table, { - filename: "vendor-target-list", - excludeColumns: ["select", "actions"], + filename: 'vendor-target-list', + excludeColumns: ['select', 'actions'], }) - }, [table]) + }, [table]); + + // EXCEL TEMPLATE DOWNLOAD + const handleTemplateDownload = useCallback(async () => { + try { + const buffer = await generateEvalTargetTemplate(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = "협력업체_평가_대상_관리_템플릿.xlsx"; + link.click(); + URL.revokeObjectURL(url); + toast.success('템플릿 파일이 다운로드되었습니다.'); + } catch (error) { + console.error('Error in Template Download: ', error); + toast.error('템플릿 다운로드 중 오류가 발생했습니다.'); + } + }, [table]); // 권한이 없거나 로딩 중인 경우 내보내기 버튼만 표시 if (roleLoading) { @@ -226,10 +294,10 @@ export function EvaluationTargetsTableToolbarActions({ disabled className="gap-2" > - <Download className="size-4 animate-spin" aria-hidden="true" /> - <span className="hidden sm:inline">로딩중...</span> + <LoaderCircle className="size-4 animate-spin" aria-hidden="true" /> + <span className="hidden sm:inline">로딩 중...</span> </Button> - </div> + </div>S </div> ) } @@ -266,6 +334,29 @@ export function EvaluationTargetsTableToolbarActions({ {/* 유틸리티 버튼들 */} <div className="flex items-center gap-1 border-l pl-2 ml-2"> + {/* 가져오기 버튼 */} + {canManageEvaluations && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleImport} + className="gap-2" + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">가져오기</span> + </Button> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + </> + )} + + {/* 내보내기 버튼 */} <Button variant="outline" size="sm" @@ -275,6 +366,19 @@ export function EvaluationTargetsTableToolbarActions({ <Download className="size-4" aria-hidden="true" /> <span className="hidden sm:inline">내보내기</span> </Button> + + {/* 템플릿 다운로드 버튼 */} + {canManageEvaluations && ( + <Button + variant="outline" + size="sm" + onClick={handleTemplateDownload} + className="gap-2" + > + <FileInput className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">템플릿 다운로드</span> + </Button> + )} </div> {/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */} |
