summaryrefslogtreecommitdiff
path: root/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-01 01:43:30 +0000
committerjoonhoekim <26rote@gmail.com>2025-10-01 01:43:30 +0000
commite47caf85b6d9d325a9291aba10ba7d50c6ab5c1f (patch)
treec7a9e4198295b2bd427c7af5d06ae29e048520fb /lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
parent4e328f0b6a5832677cfd23f49ff71e3e203026e7 (diff)
(고건) 협력업체 평가 대상 관리 페이지 내 엑셀 템플릿 다운로드 및 엑셀 데이터 업로드 기능 추가
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.tsx278
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>
{/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */}