diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-15 01:19:49 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-15 01:19:49 +0000 |
| commit | 9eb8e80f4f736c4edffa650c685d1f170ca51aa1 (patch) | |
| tree | cae02173015c806cd5ea92be86938fe3bf14decd /components/data-table | |
| parent | 71f4dd76b57e77676d8886ac0a8b0bd0a7f24e62 (diff) | |
(대표님) 구매 요청사항 반영한 통합 rfq / 필터 개인화 / po-rfq
Diffstat (limited to 'components/data-table')
| -rw-r--r-- | components/data-table/data-table-preset.tsx | 373 | ||||
| -rw-r--r-- | components/data-table/use-table-presets.tsx | 338 |
2 files changed, 711 insertions, 0 deletions
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<TData = any> { + presets: TablePreset[] + activePresetId: string | null + currentSettings: TableSettings<TData> + hasUnsavedChanges: boolean + isLoading: boolean + onCreatePreset: (name: string, settings: TableSettings<TData>, isDefault: boolean) => Promise<boolean> + onUpdatePreset: (presetId: string, settings: TableSettings<TData>) => Promise<void> + onDeletePreset: (presetId: string) => Promise<boolean> + onApplyPreset: (presetId: string) => Promise<boolean> + onSetDefaultPreset: (presetId: string) => Promise<void> + onRenamePreset: (presetId: string, newName: string) => Promise<boolean> +} + +export function TablePresetManager<TData = any>({ + presets, + activePresetId, + currentSettings, + hasUnsavedChanges, + isLoading, + onCreatePreset, + onUpdatePreset, + onDeletePreset, + onApplyPreset, + onSetDefaultPreset, + onRenamePreset, +}: TablePresetManagerProps<TData>) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) + const [newPresetName, setNewPresetName] = useState('') + const [isDefaultPreset, setIsDefaultPreset] = useState(false) + const [renamingPresetId, setRenamingPresetId] = useState<string | null>(null) + const [processingPresetId, setProcessingPresetId] = useState<string | null>(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 ( + <Button variant="outline" size="sm" className="h-8" disabled> + <Loader2 size={16} className="mr-2 animate-spin" /> + 로딩 중... + </Button> + ) + } + + return ( + <> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="h-8"> + <Settings size={16} className="mr-2" /> + {/* <span className="hidden sm:inline">맞춤 설정</span> */} + {activePreset ? ( + <div className="flex items-center ml-2"> + <span className="font-medium text-sm">{activePreset.name}</span> + {hasUnsavedChanges && <span className="ml-1 text-amber-500">*</span>} + {activePreset.isDefault && <Star size={12} className="ml-1 text-yellow-500" />} + </div> + ) : null} + <ChevronDown size={14} className="ml-1" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-72"> + <DropdownMenuLabel className="flex items-center"> + <Bookmark size={16} className="mr-2" /> + 테이블 맞춤 설정 + </DropdownMenuLabel> + <DropdownMenuSeparator /> + + {/* 활성 맞춤 설정 표시 */} + {activePreset && ( + <> + <div className="px-2 py-2 bg-muted/50 rounded-sm mx-1"> + <div className="text-xs text-muted-foreground">현재 활성</div> + <div className="flex items-center justify-between"> + <span className="font-medium text-sm">{activePreset.name}</span> + <div className="flex items-center gap-1"> + {activePreset.isDefault && <Star size={12} className="text-yellow-500" />} + {hasUnsavedChanges && <span className="text-amber-500 text-xs">수정됨</span>} + </div> + </div> + </div> + <DropdownMenuSeparator /> + </> + )} + + {/* 빠른 액션 */} + <DropdownMenuItem onClick={() => setIsCreateDialogOpen(true)}> + <Plus size={14} className="mr-2" /> + 현재 설정으로 새 맞춤 설정 만들기 + </DropdownMenuItem> + + {activePresetId && ( + <DropdownMenuItem onClick={handleUpdateCurrentPreset}> + <Save size={14} className="mr-2" /> + {hasUnsavedChanges ? '변경 내용 저장' : '현재 설정 업데이트'} + </DropdownMenuItem> + )} + + <DropdownMenuSeparator /> + + {/* 맞춤 설정 목록 */} + <DropdownMenuLabel className="text-xs text-muted-foreground"> + 저장된 맞춤 설정 + </DropdownMenuLabel> + + {presets.length === 0 ? ( + <div className="px-2 py-2 text-center text-sm text-muted-foreground"> + 저장된 프리셋이 없습니다 + </div> + ) : ( + presets.map((preset) => ( + <DropdownMenuSub key={preset.id}> + <DropdownMenuSubTrigger> + <div className="flex items-center w-full"> + {preset.id === activePresetId ? ( + <Check size={14} className="mr-2" /> + ) : ( + <div className="w-[14px] mr-2" /> + )} + <span className={preset.id === activePresetId ? "font-medium" : ""}> + {preset.name} + </span> + {preset.isDefault && <Star size={12} className="ml-1 text-yellow-500" />} + </div> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-48"> + <DropdownMenuItem onClick={() => handleApplyPreset(preset.id)}> + {processingPresetId === preset.id ? ( + <Loader2 size={14} className="mr-2 animate-spin" /> + ) : ( + <Check size={14} className="mr-2" /> + )} + 적용 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => openRenameDialog(preset)}> + <Edit size={14} className="mr-2" /> + 이름 변경 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onSetDefaultPreset(preset.id)}> + <Star size={14} className="mr-2" /> + {preset.isDefault ? '기본 해제' : '기본으로 설정'} + </DropdownMenuItem> + <DropdownMenuSeparator /> + {presets.length > 1 && ( + <DropdownMenuItem + onClick={() => onDeletePreset(preset.id)} + className="text-red-600 focus:text-red-600" + > + <Trash2 size={14} className="mr-2" /> + 삭제 + </DropdownMenuItem> + )} + </DropdownMenuSubContent> + </DropdownMenuSub> + )) + )} + </DropdownMenuContent> + </DropdownMenu> + + {/* Create Dialog */} + <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>새 맞춤 설정 저장</DialogTitle> + <DialogDescription> + 현재 테이블 설정을 새로운 프리셋으로 저장합니다. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="presetName" className="text-right"> + 이름 + </Label> + <div className="col-span-3"> + <Input + id="presetName" + value={newPresetName} + onChange={(e) => setNewPresetName(e.target.value)} + placeholder="맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreatePreset() + } + }} + /> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label className="text-right">옵션</Label> + <div className="col-span-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id="isDefault" + checked={isDefaultPreset} + onCheckedChange={(checked) => setIsDefaultPreset(checked as boolean)} + /> + <Label htmlFor="isDefault" className="text-sm font-normal"> + 기본 프리셋으로 설정 + </Label> + </div> + <p className="text-xs text-muted-foreground mt-1"> + 기본 프리셋은 테이블 로드 시 자동으로 적용됩니다. + </p> + </div> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setIsCreateDialogOpen(false) + setNewPresetName('') + setIsDefaultPreset(false) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={handleCreatePreset} + disabled={!newPresetName.trim()} + > + <Save size={14} className="mr-2" /> + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Rename Dialog */} + <Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>맞춤 설정 이름 변경</DialogTitle> + <DialogDescription> + '{presets.find(p => p.id === renamingPresetId)?.name}' 프리셋의 이름을 변경합니다. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="renamePresetName" className="text-right"> + 새 이름 + </Label> + <div className="col-span-3"> + <Input + id="renamePresetName" + value={newPresetName} + onChange={(e) => setNewPresetName(e.target.value)} + placeholder="새 맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenamePreset() + } + }} + /> + </div> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setIsRenameDialogOpen(false) + setNewPresetName('') + setRenamingPresetId(null) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={handleRenamePreset} + disabled={!newPresetName.trim()} + > + <Edit size={14} className="mr-2" /> + 변경 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ 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<TData>( + tableId: string, + initialSettings: TableSettings<TData> +) { + 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<TablePreset[]>( + 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<TData> => { + // 서버 렌더링 중이면 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<TData>, + 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<TData> + ) => { + 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<TableSettings<TData>>) => { + 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 |
