From e47caf85b6d9d325a9291aba10ba7d50c6ab5c1f Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Wed, 1 Oct 2025 01:43:30 +0000
Subject: (고건) 협력업체 평가 대상 관리 페이지 내 엑셀 템플릿 다운로드 및 엑셀
 데이터 업로드 기능 추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../table/evaluation-targets-toolbar-actions.tsx   | 278 ++++++++++++++-------
 1 file changed, 191 insertions(+), 87 deletions(-)

(limited to 'lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx')

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>
 
         {/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */}
-- 
cgit v1.2.3

