summaryrefslogtreecommitdiff
path: root/components/data-table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-15 01:19:49 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-15 01:19:49 +0000
commit9eb8e80f4f736c4edffa650c685d1f170ca51aa1 (patch)
treecae02173015c806cd5ea92be86938fe3bf14decd /components/data-table
parent71f4dd76b57e77676d8886ac0a8b0bd0a7f24e62 (diff)
(대표님) 구매 요청사항 반영한 통합 rfq / 필터 개인화 / po-rfq
Diffstat (limited to 'components/data-table')
-rw-r--r--components/data-table/data-table-preset.tsx373
-rw-r--r--components/data-table/use-table-presets.tsx338
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