From 9eb8e80f4f736c4edffa650c685d1f170ca51aa1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 15 May 2025 01:19:49 +0000 Subject: (대표님) 구매 요청사항 반영한 통합 rfq / 필터 개인화 / po-rfq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/data-table/data-table-preset.tsx | 373 ++++++++++++++++++++++++++++ components/data-table/use-table-presets.tsx | 338 +++++++++++++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 components/data-table/data-table-preset.tsx create mode 100644 components/data-table/use-table-presets.tsx (limited to 'components/data-table') diff --git a/components/data-table/data-table-preset.tsx b/components/data-table/data-table-preset.tsx new file mode 100644 index 00000000..007bde2f --- /dev/null +++ b/components/data-table/data-table-preset.tsx @@ -0,0 +1,373 @@ +// components/data-table/table-preset-manager.tsx +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@/components/ui/dropdown-menu' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Settings, + Save, + Star, + Edit, + Trash2, + Plus, + Check, + Loader2, + ChevronDown, + Bookmark +} from 'lucide-react' +import { TableSettings } from '@/db/schema' + +interface TablePreset { + id: string + name: string + isDefault: boolean + isActive: boolean + settings: TableSettings +} + +interface TablePresetManagerProps { + presets: TablePreset[] + activePresetId: string | null + currentSettings: TableSettings + hasUnsavedChanges: boolean + isLoading: boolean + onCreatePreset: (name: string, settings: TableSettings, isDefault: boolean) => Promise + onUpdatePreset: (presetId: string, settings: TableSettings) => Promise + onDeletePreset: (presetId: string) => Promise + onApplyPreset: (presetId: string) => Promise + onSetDefaultPreset: (presetId: string) => Promise + onRenamePreset: (presetId: string, newName: string) => Promise +} + +export function TablePresetManager({ + presets, + activePresetId, + currentSettings, + hasUnsavedChanges, + isLoading, + onCreatePreset, + onUpdatePreset, + onDeletePreset, + onApplyPreset, + onSetDefaultPreset, + onRenamePreset, +}: TablePresetManagerProps) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) + const [newPresetName, setNewPresetName] = useState('') + const [isDefaultPreset, setIsDefaultPreset] = useState(false) + const [renamingPresetId, setRenamingPresetId] = useState(null) + const [processingPresetId, setProcessingPresetId] = useState(null) + + const activePreset = presets.find(p => p.id === activePresetId) + + const handleCreatePreset = async () => { + if (!newPresetName.trim()) return + + const success = await onCreatePreset(newPresetName, currentSettings, isDefaultPreset) + if (success) { + setIsCreateDialogOpen(false) + setNewPresetName('') + setIsDefaultPreset(false) + } + } + + const handleRenamePreset = async () => { + if (renamingPresetId && newPresetName.trim()) { + const success = await onRenamePreset(renamingPresetId, newPresetName) + if (success) { + setIsRenameDialogOpen(false) + setNewPresetName('') + setRenamingPresetId(null) + } + } + } + + const openRenameDialog = (preset: TablePreset) => { + setRenamingPresetId(preset.id) + setNewPresetName(preset.name) + setIsRenameDialogOpen(true) + } + + const handleUpdateCurrentPreset = async () => { + if (activePresetId) { + await onUpdatePreset(activePresetId, currentSettings) + } + } + + const handleApplyPreset = async (presetId: string) => { + setProcessingPresetId(presetId) + await onApplyPreset(presetId) + setProcessingPresetId(null) + } + + if (isLoading) { + return ( + + ) + } + + return ( + <> + + + + + + + + 테이블 맞춤 설정 + + + + {/* 활성 맞춤 설정 표시 */} + {activePreset && ( + <> +
+
현재 활성
+
+ {activePreset.name} +
+ {activePreset.isDefault && } + {hasUnsavedChanges && 수정됨} +
+
+
+ + + )} + + {/* 빠른 액션 */} + setIsCreateDialogOpen(true)}> + + 현재 설정으로 새 맞춤 설정 만들기 + + + {activePresetId && ( + + + {hasUnsavedChanges ? '변경 내용 저장' : '현재 설정 업데이트'} + + )} + + + + {/* 맞춤 설정 목록 */} + + 저장된 맞춤 설정 + + + {presets.length === 0 ? ( +
+ 저장된 프리셋이 없습니다 +
+ ) : ( + presets.map((preset) => ( + + +
+ {preset.id === activePresetId ? ( + + ) : ( +
+ )} + + {preset.name} + + {preset.isDefault && } +
+ + + handleApplyPreset(preset.id)}> + {processingPresetId === preset.id ? ( + + ) : ( + + )} + 적용 + + openRenameDialog(preset)}> + + 이름 변경 + + onSetDefaultPreset(preset.id)}> + + {preset.isDefault ? '기본 해제' : '기본으로 설정'} + + + {presets.length > 1 && ( + onDeletePreset(preset.id)} + className="text-red-600 focus:text-red-600" + > + + 삭제 + + )} + + + )) + )} + + + + {/* Create Dialog */} + + + + 새 맞춤 설정 저장 + + 현재 테이블 설정을 새로운 프리셋으로 저장합니다. + + +
+
+ +
+ setNewPresetName(e.target.value)} + placeholder="맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreatePreset() + } + }} + /> +
+
+
+ +
+
+ setIsDefaultPreset(checked as boolean)} + /> + +
+

+ 기본 프리셋은 테이블 로드 시 자동으로 적용됩니다. +

+
+
+
+ + + + +
+
+ + {/* Rename Dialog */} + + + + 맞춤 설정 이름 변경 + + '{presets.find(p => p.id === renamingPresetId)?.name}' 프리셋의 이름을 변경합니다. + + +
+
+ +
+ setNewPresetName(e.target.value)} + placeholder="새 맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenamePreset() + } + }} + /> +
+
+
+ + + + +
+
+ + ) +} \ No newline at end of file diff --git a/components/data-table/use-table-presets.tsx b/components/data-table/use-table-presets.tsx new file mode 100644 index 00000000..5e641762 --- /dev/null +++ b/components/data-table/use-table-presets.tsx @@ -0,0 +1,338 @@ +// hooks/use-table-presets.ts +import { TableSettings } from "@/db/schema" +import { useSession } from "next-auth/react" +import { useCallback, useEffect, useState } from "react" +import { toast } from "sonner" +import useSWR from "swr" +import { useSearchParams } from "next/navigation" + +interface TablePreset { + id: string + userId: string + tableId: string + name: string + settings: TableSettings + isDefault: boolean + isActive: boolean + isShared: boolean + createdAt: Date + updatedAt: Date +} + +export function useTablePresets( + tableId: string, + initialSettings: TableSettings +) { + const { data: session } = useSession() + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [isClient, setIsClient] = useState(false) + const searchParams = useSearchParams() + + // 클라이언트 마운트 확인 + useEffect(() => { + setIsClient(true) + }, []) + + // SWR을 사용한 데이터 페칭 + const { + data: presets, + error, + mutate, + isLoading + } = useSWR( + session?.user ? `/api/table-presets?tableId=${tableId}` : null, + (url: string) => fetch(url).then(res => res.json()), + { + revalidateOnFocus: false, + revalidateOnReconnect: true, + } + ) + + const activePreset = presets?.find(p => p.isActive) + + // 현재 설정 가져오기 (URL + 활성 프리셋의 클라이언트 상태) + const getCurrentSettings = useCallback((): TableSettings => { + // 서버 렌더링 중이면 initialSettings 사용 + if (!isClient || typeof window === 'undefined') { + return initialSettings + } + + // 클라이언트에서만 URL 파라미터 읽기 + const urlSettings = { + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: JSON.parse(searchParams.get('sort') || JSON.stringify(initialSettings.sort)), + filters: JSON.parse(searchParams.get('filters') || '[]'), + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: JSON.parse(searchParams.get('basicFilters') || '[]'), + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + from: searchParams.get('from') || undefined, + to: searchParams.get('to') || undefined, + } + + // 활성 프리셋의 클라이언트 설정 병합 + if (activePreset) { + return { + ...urlSettings, + columnVisibility: activePreset.settings.columnVisibility || {}, + columnOrder: activePreset.settings.columnOrder || [], + pinnedColumns: activePreset.settings.pinnedColumns || { left: [], right: [] }, + groupBy: activePreset.settings.groupBy || [], + expandedRows: activePreset.settings.expandedRows || [] + } + } + + return urlSettings + }, [activePreset, initialSettings, isClient, searchParams]) + + // 프리셋 생성 + const createPreset = useCallback(async ( + name: string, + settings: TableSettings, + isDefault = false + ) => { + try { + const response = await fetch('/api/table-presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tableId, + name, + settings, + isDefault + }) + }) + + if (!response.ok) { + throw new Error('Failed to create preset') + } + + await mutate() + toast.success(`프리셋 '${name}'이 저장되었습니다`) + return true + } catch (error) { + console.error('Error creating preset:', error) + toast.error('프리셋 저장 중 오류가 발생했습니다') + return false + } + }, [tableId, mutate]) + + // 프리셋 적용 + const applyPreset = useCallback(async (presetId: string) => { + try { + // 이전 활성 프리셋 비활성화 (있는 경우) + if (activePreset && activePreset.id !== presetId) { + await fetch(`/api/table-presets/${activePreset.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: false }) + }) + } + + // 새 프리셋 활성화 + await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: true }) + }) + + const preset = presets?.find(p => p.id === presetId) + if (!preset) return false + + // 클라이언트에서만 URL 업데이트 + if (isClient && typeof window !== 'undefined') { + const params = new URLSearchParams() + if (preset.settings.page !== 1) params.set('page', preset.settings.page.toString()) + if (preset.settings.perPage !== 10) params.set('perPage', preset.settings.perPage.toString()) + if (preset.settings.sort && preset.settings.sort.length > 0) params.set('sort', JSON.stringify(preset.settings.sort)) + if (preset.settings.filters && preset.settings.filters.length > 0) params.set('filters', JSON.stringify(preset.settings.filters)) + if (preset.settings.joinOperator !== 'and') params.set('joinOperator', preset.settings.joinOperator) + if (preset.settings.basicFilters && preset.settings.basicFilters.length > 0) params.set('basicFilters', JSON.stringify(preset.settings.basicFilters)) + if (preset.settings.basicJoinOperator !== 'and') params.set('basicJoinOperator', preset.settings.basicJoinOperator) + if (preset.settings.search) params.set('search', preset.settings.search) + if (preset.settings.from) params.set('from', preset.settings.from) + if (preset.settings.to) params.set('to', preset.settings.to) + + const url = window.location.pathname + '?' + params.toString() + window.history.pushState({}, '', url) + } + + await mutate() + + // Next.js App Router의 경우 router.push나 window.location.reload 사용 + if (isClient && typeof window !== 'undefined') { + window.location.reload() + } + + return true + } catch (error) { + console.error('Error applying preset:', error) + toast.error('프리셋 적용 중 오류가 발생했습니다') + return false + } + }, [activePreset, presets, mutate, isClient]) + + // 프리셋 업데이트 + const updatePreset = useCallback(async ( + presetId: string, + settings: TableSettings + ) => { + try { + const response = await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }) + }) + + if (!response.ok) { + throw new Error('Failed to update preset') + } + + await mutate() + const preset = presets?.find(p => p.id === presetId) + toast.success(`${preset?.name || '프리셋'}이 업데이트되었습니다`) + } catch (error) { + console.error('Error updating preset:', error) + toast.error('프리셋 업데이트 중 오류가 발생했습니다') + } + }, [presets, mutate]) + + // 프리셋 삭제 + const deletePreset = useCallback(async (presetId: string) => { + try { + const response = await fetch(`/api/table-presets/${presetId}`, { + method: 'DELETE' + }) + + if (!response.ok) { + throw new Error('Failed to delete preset') + } + + // 삭제할 프리셋이 활성 프리셋인 경우 다른 프리셋을 활성화 + if (activePreset?.id === presetId && presets && presets.length > 1) { + const defaultPreset = presets.find(p => p.isDefault && p.id !== presetId) || + presets.find(p => p.id !== presetId) + if (defaultPreset) { + await applyPreset(defaultPreset.id) + return true + } + } + + await mutate() + const preset = presets?.find(p => p.id === presetId) + toast.success(`프리셋 '${preset?.name}'이 삭제되었습니다`) + return true + } catch (error) { + console.error('Error deleting preset:', error) + toast.error('프리셋 삭제 중 오류가 발생했습니다') + return false + } + }, [activePreset, presets, mutate, applyPreset]) + + // 기본 프리셋 설정 + const setDefaultPreset = useCallback(async (presetId: string) => { + try { + // 기존 기본 프리셋 해제 + const defaultPreset = presets?.find(p => p.isDefault) + if (defaultPreset && defaultPreset.id !== presetId) { + await fetch(`/api/table-presets/${defaultPreset.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isDefault: false }) + }) + } + + // 새 기본 프리셋 설정 + await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isDefault: true }) + }) + + await mutate() + toast.success('기본 프리셋이 변경되었습니다') + } catch (error) { + console.error('Error setting default preset:', error) + toast.error('기본 프리셋 설정 중 오류가 발생했습니다') + } + }, [presets, mutate]) + + // 프리셋 이름 변경 + const renamePreset = useCallback(async (presetId: string, newName: string) => { + try { + if (!newName.trim()) { + toast.error('프리셋 이름을 입력해주세요') + return false + } + + const response = await fetch(`/api/table-presets/${presetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName.trim() }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || 'Failed to rename preset') + } + + await mutate() + toast.success('프리셋 이름이 변경되었습니다') + return true + } catch (error) { + console.error('Error renaming preset:', error) + toast.error('프리셋 이름 변경 중 오류가 발생했습니다') + return false + } + }, [mutate]) + + // 클라이언트 상태 업데이트 (컬럼 가시성, 핀 등) + const updateClientState = useCallback(async (newClientState: Partial>) => { + if (!activePreset) return + + const updatedSettings = { + ...activePreset.settings, + ...newClientState + } + + await updatePreset(activePreset.id, updatedSettings) + }, [activePreset, updatePreset]) + + // URL 변경 감지 및 미저장 변경사항 체크 + useEffect(() => { + if (!isClient || !presets || !activePreset) return + + const currentSettings = getCurrentSettings() + + // 현재 URL 설정과 활성 프리셋 설정 비교 + const isSettingsChanged = + currentSettings.perPage !== activePreset.settings.perPage || + JSON.stringify(currentSettings.sort) !== JSON.stringify(activePreset.settings.sort) || + JSON.stringify(currentSettings.filters) !== JSON.stringify(activePreset.settings.filters) || + currentSettings.joinOperator !== activePreset.settings.joinOperator || + JSON.stringify(currentSettings.basicFilters) !== JSON.stringify(activePreset.settings.basicFilters) || + currentSettings.basicJoinOperator !== activePreset.settings.basicJoinOperator || + currentSettings.search !== activePreset.settings.search + + setHasUnsavedChanges(isSettingsChanged) + }, [isClient, presets, activePreset, getCurrentSettings]) + + return { + // 상태 + presets: presets || [], + activePresetId: activePreset?.id || null, + isLoading, + hasUnsavedChanges, + + // 액션 + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } +} \ No newline at end of file -- cgit v1.2.3