From 30f4150dfa4e573b5ba9b535632297df2c94b882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 19 Mar 2026 17:48:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[common]=20=EA=B2=80=EC=83=89=20X=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AC=EC=96=B4=20=EB=B2=84=ED=8A=BC=20+=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=83=81=ED=83=9C=20sessionStorage=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchFilter, IntegratedListTemplateV2: 검색 입력 시 X(클리어) 버튼 표시 - useListSearchState 훅 신규: URL + sessionStorage 이중 저장으로 상세→목록 복귀 시 검색 유지 - UniversalListPage: useListSearchState 연동 --- src/components/organisms/SearchFilter.tsx | 13 +- .../templates/IntegratedListTemplateV2.tsx | 24 +- .../templates/UniversalListPage/index.tsx | 14 +- src/hooks/index.ts | 8 + src/hooks/useListSearchState.ts | 297 ++++++++++++++++++ 5 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useListSearchState.ts diff --git a/src/components/organisms/SearchFilter.tsx b/src/components/organisms/SearchFilter.tsx index af2b2f20..afa45af9 100644 --- a/src/components/organisms/SearchFilter.tsx +++ b/src/components/organisms/SearchFilter.tsx @@ -1,7 +1,7 @@ "use client"; import { Input } from "@/components/ui/input"; -import { Search } from "lucide-react"; +import { Search, XCircle } from "lucide-react"; import { ReactNode, useState, useEffect } from "react"; interface SearchFilterProps { @@ -45,8 +45,17 @@ export function SearchFilter({ placeholder={isMobile ? "내용을 검색해주세요." : searchPlaceholder} value={searchValue} onChange={(e) => onSearchChange(e.target.value)} - className="pl-10 text-[14px]" + className="pl-10 pr-8 text-[14px]" /> + {searchValue && ( + + )} {extraActions && (
diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx index 44a115c1..e6dbbe8e 100644 --- a/src/components/templates/IntegratedListTemplateV2.tsx +++ b/src/components/templates/IntegratedListTemplateV2.tsx @@ -1,7 +1,7 @@ "use client"; import { ReactNode, Fragment, useState, useEffect, useRef, useCallback, Children, isValidElement, cloneElement } from "react"; -import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react"; +import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search, XCircle } from "lucide-react"; import { DateRangeSelector } from "@/components/molecules/DateRangeSelector"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent } from "@/components/ui/tabs"; @@ -691,8 +691,17 @@ export function IntegratedListTemplateV2({ placeholder={searchPlaceholder} value={searchValue || ''} onChange={(e) => onSearchChange(e.target.value)} - className="pl-9 w-full bg-gray-50 border-gray-200" + className="pl-9 pr-8 w-full bg-gray-50 border-gray-200" /> + {searchValue && ( + + )}
)} {/* 기존 extraActions (추가 버튼 등) */} @@ -710,8 +719,17 @@ export function IntegratedListTemplateV2({ placeholder={searchPlaceholder} value={searchValue || ''} onChange={(e) => onSearchChange(e.target.value)} - className="pl-9 w-full bg-gray-50 border-gray-200" + className="pl-9 pr-8 w-full bg-gray-50 border-gray-200" /> + {searchValue && ( + + )} ) )} diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index bd6ea562..854db2a0 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -15,6 +15,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useRouter, useParams } from 'next/navigation'; import { usePermission } from '@/hooks/usePermission'; import { useColumnSettings } from '@/hooks/useColumnSettings'; +import { useListSearchState } from '@/hooks/useListSearchState'; import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover'; import { toast } from 'sonner'; import { Download, Loader2 } from 'lucide-react'; @@ -48,14 +49,21 @@ export function UniversalListPage({ const locale = (params.locale as string) || 'ko'; const { canCreate: permCanCreate, canDelete: permCanDelete, canExport } = usePermission(); + // ===== 검색 상태 보존 (sessionStorage + URL) ===== + const { + getValue: getSearchStateValue, + setValue: setSearchStateValue, + } = useListSearchState({ debounceMs: 500 }); + const restoredSearch = getSearchStateValue('search'); + // ===== 상태 관리 ===== // 원본 데이터 (클라이언트 사이드 필터링용) const [rawData, setRawData] = useState(initialData || []); // UI 상태 const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(!initialData); - const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영) - const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced) + const [searchValue, setSearchValue] = useState(restoredSearch); // UI 입력용 (즉시 반영, sessionStorage에서 복원) + const [debouncedSearchValue, setDebouncedSearchValue] = useState(restoredSearch); // API 호출용 (debounced) const [selectedItems, setSelectedItems] = useState>(new Set()); // 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함 const [activeTab, setActiveTab] = useState( @@ -536,7 +544,7 @@ export function UniversalListPage({ // ===== 검색 핸들러 ===== const handleSearchChange = useCallback((value: string) => { setSearchValue(value); // UI 즉시 반영 - // 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리) + setSearchStateValue('search', value); // sessionStorage + URL 동기화 }, []); // 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index af8c00f5..f2a21cb7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -37,6 +37,14 @@ export type { UseDetailPermissionsReturn, } from './useDetailPermissions'; +// ===== 리스트 검색 상태 보존 ===== +export { useListSearchState } from './useListSearchState'; +export type { + SearchStateField, + UseListSearchStateOptions, + UseListSearchStateReturn, +} from './useListSearchState'; + // ===== 날짜 범위 / 목록 페이지 ===== export { useDateRange } from './useDateRange'; export type { DateRangePreset, UseDateRangeReturn } from './useDateRange'; diff --git a/src/hooks/useListSearchState.ts b/src/hooks/useListSearchState.ts new file mode 100644 index 00000000..c4fa1fad --- /dev/null +++ b/src/hooks/useListSearchState.ts @@ -0,0 +1,297 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; + +/** + * 검색 상태 필드 설정 + */ +export interface SearchStateField { + /** URL 쿼리 파라미터 키 */ + key: string; + /** 기본값 */ + defaultValue: string; +} + +export interface UseListSearchStateOptions { + /** 관리할 검색 상태 필드들 (기본: search, tab) */ + fields?: SearchStateField[]; + /** URL 업데이트 디바운스 ms (기본: 300) */ + debounceMs?: number; +} + +export interface UseListSearchStateReturn { + /** 검색 상태 값 조회 */ + getValue: (key: string) => string; + /** 검색 상태 값 변경 (URL 자동 동기화) */ + setValue: (key: string, value: string) => void; + /** 현재 페이지 번호 */ + currentPage: number; + /** 페이지 변경 */ + setPage: (page: number) => void; + /** 전체 상태 초기화 */ + resetAll: () => void; + /** 현재 목록 URL (쿼리 포함) — 상세페이지에서 돌아올 때 사용 */ + listUrl: string; +} + +const DEFAULT_FIELDS: SearchStateField[] = [ + { key: 'search', defaultValue: '' }, + { key: 'tab', defaultValue: 'all' }, +]; + +// ===== sessionStorage 헬퍼 ===== +function getStorageKey(pathname: string): string { + return `listSearch:${pathname}`; +} + +function saveToStorage(pathname: string, state: Record) { + if (typeof window === 'undefined') return; + try { + sessionStorage.setItem(getStorageKey(pathname), JSON.stringify(state)); + } catch { + // sessionStorage 용량 초과 등 무시 + } +} + +function loadFromStorage(pathname: string): Record | null { + if (typeof window === 'undefined') return null; + try { + const raw = sessionStorage.getItem(getStorageKey(pathname)); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** + * 리스트 페이지 검색 상태 보존 훅 + * + * URL 쿼리파라미터 + sessionStorage 이중 저장으로 + * 상세 페이지 이동 후 돌아왔을 때 검색 상태가 유지됩니다. + * + * 우선순위: URL 파라미터 > sessionStorage > 기본값 + */ +export function useListSearchState( + options: UseListSearchStateOptions = {} +): UseListSearchStateReturn { + const { fields = DEFAULT_FIELDS, debounceMs = 300 } = options; + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // fields를 ref로 안정화 + const fieldsRef = useRef(fields); + fieldsRef.current = fields; + + // 초기값: URL 파라미터 > sessionStorage > 기본값 + const getInitialValues = (): Record => { + const values: Record = {}; + let hasUrlParams = false; + + for (const field of fieldsRef.current) { + const urlValue = searchParams?.get(field.key); + if (urlValue !== null) { + values[field.key] = urlValue; + hasUrlParams = true; + } + } + const urlPage = searchParams?.get('page'); + if (urlPage) hasUrlParams = true; + + // URL에 파라미터가 있으면 URL 우선 + if (hasUrlParams) { + for (const field of fieldsRef.current) { + if (values[field.key] === undefined) { + values[field.key] = field.defaultValue; + } + } + values._page = urlPage ?? '1'; + return values; + } + + // URL에 없으면 sessionStorage 복원 시도 + const stored = loadFromStorage(pathname); + if (stored) { + return stored; + } + + // 둘 다 없으면 기본값 + for (const field of fieldsRef.current) { + values[field.key] = field.defaultValue; + } + values._page = '1'; + return values; + }; + + // 로컬 상태 + const [localState, setLocalState] = useState>(getInitialValues); + + // ref로 현재 상태 추적 + const stateRef = useRef(localState); + stateRef.current = localState; + + // URL 업데이트 타이머 + 내부 업데이트 추적 + const timerRef = useRef | null>(null); + const isInternalUpdate = useRef(false); + + // URL → 로컬 상태 동기화 (브라우저 뒤로가기 대응) + useEffect(() => { + if (isInternalUpdate.current) { + isInternalUpdate.current = false; + return; + } + // URL에 파라미터가 없으면 sessionStorage에서 복원 + let hasUrlParams = false; + for (const field of fieldsRef.current) { + if (searchParams?.get(field.key) !== null) { + hasUrlParams = true; + break; + } + } + if (searchParams?.get('page') !== null) hasUrlParams = true; + + let newValues: Record; + if (hasUrlParams) { + newValues = {} as Record; + for (const field of fieldsRef.current) { + newValues[field.key] = searchParams?.get(field.key) ?? field.defaultValue; + } + newValues._page = searchParams?.get('page') ?? '1'; + } else { + const stored = loadFromStorage(pathname); + if (stored) { + newValues = stored; + // sessionStorage에서 복원한 상태를 URL에도 반영 + queueMicrotask(() => syncToUrlImmediate(stored)); + } else { + return; // 기본값이면 변경 불필요 + } + } + + const current = stateRef.current; + const isDifferent = Object.keys(newValues).some( + (key) => newValues[key] !== current[key] + ); + if (isDifferent) { + setLocalState(newValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + // URL 즉시 업데이트 (디바운스 없이) + const syncToUrlImmediate = useCallback( + (state: Record) => { + isInternalUpdate.current = true; + const params = new URLSearchParams(); + for (const field of fieldsRef.current) { + const value = state[field.key]; + if (value && value !== field.defaultValue) { + params.set(field.key, value); + } + } + const page = state._page; + if (page && page !== '1') { + params.set('page', page); + } + const query = params.toString(); + const url = query ? `${pathname}?${query}` : pathname; + router.replace(url, { scroll: false }); + }, + [pathname, router] + ); + + // 로컬 상태 → URL + sessionStorage 동기화 (디바운스) + const syncToUrl = useCallback( + (newState: Record) => { + // sessionStorage는 즉시 저장 (디바운스 없이) + saveToStorage(pathname, newState); + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + syncToUrlImmediate(newState); + }, debounceMs); + }, + [pathname, debounceMs, syncToUrlImmediate] + ); + + // 값 조회 + const getValue = useCallback( + (key: string): string => { + return localState[key] ?? fieldsRef.current.find((f) => f.key === key)?.defaultValue ?? ''; + }, + [localState] + ); + + // 값 변경 + const setValue = useCallback( + (key: string, value: string) => { + setLocalState((prev) => { + if (prev[key] === value) return prev; + const next = { ...prev, [key]: value }; + if (key !== '_page') { + next._page = '1'; + } + queueMicrotask(() => syncToUrl(next)); + return next; + }); + }, + [syncToUrl] + ); + + // 페이지 + const currentPage = parseInt(localState._page || '1', 10); + const setPage = useCallback( + (page: number) => { + setValue('_page', String(page)); + }, + [setValue] + ); + + // 초기화 + const resetAll = useCallback(() => { + const defaults: Record = {}; + for (const field of fieldsRef.current) { + defaults[field.key] = field.defaultValue; + } + defaults._page = '1'; + setLocalState(defaults); + saveToStorage(pathname, defaults); + isInternalUpdate.current = true; + router.replace(pathname, { scroll: false }); + }, [pathname, router]); + + // 현재 목록 URL (쿼리 포함) + const listUrl = useMemo(() => { + const params = new URLSearchParams(); + for (const field of fieldsRef.current) { + const value = localState[field.key]; + if (value && value !== field.defaultValue) { + params.set(field.key, value); + } + } + const page = localState._page; + if (page && page !== '1') { + params.set('page', page); + } + const query = params.toString(); + return query ? `${pathname}?${query}` : pathname; + }, [localState, pathname]); + + // cleanup + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + return { + getValue, + setValue, + currentPage, + setPage, + resetAll, + listUrl, + }; +}