diff --git a/claudedocs/guides/UniversalListPage-검색기능-수정내역.md b/claudedocs/guides/UniversalListPage-검색기능-수정내역.md new file mode 100644 index 00000000..1afa200b --- /dev/null +++ b/claudedocs/guides/UniversalListPage-검색기능-수정내역.md @@ -0,0 +1,80 @@ +# UniversalListPage 검색 기능 수정 내역 + +## 배경 + +UniversalListPage 템플릿을 사용하는 15개 리스트 페이지에서 검색 기능이 미작동하거나, 검색 시 리렌더링이 발생하는 문제가 있었음. + +## 문제 분류 + +| 유형 | 페이지 수 | 설명 | +|------|----------|------| +| 검색 미작동 | 10개 | 검색어 입력해도 필터링 안됨 | +| 검색 오류 | 1개 | 검색 시 에러 발생 | +| 검색 시 리렌더링 | 4개 | 검색 입력 시 페이지 전체 리렌더링 | + +## 대상 페이지 (15개) + +| # | 페이지 | 패턴 | 증상 | +|---|--------|------|------| +| 1 | approval/inbox | B (externalPagination) | 검색 미작동 | +| 2 | approval/reference | B | 검색 미작동 | +| 3 | boards/free | B | 검색 미작동 | +| 4 | boards/board_mjsgri54_1fmg | B | 검색 미작동 | +| 5 | settings/accounts | A (fetchData) | 검색 미작동 | +| 6 | sales/pricing-management | A | 검색 오류 | +| 7 | production/work-orders | A | 리렌더링 | +| 8 | production/work-results | A | 리렌더링 | +| 9 | material/receiving-management | A | 리렌더링 | +| 10 | outbound/shipments | A | 리렌더링 | +| 11 | accounting/vendor-ledger | B | 검색 미작동 | +| 12 | accounting/bills | A | 검색 미작동 | +| 13 | accounting/bank-transactions | A | 검색 미작동 | +| 14 | accounting/expected-expenses | A | 검색 미작동 | +| 15 | payment-history | - | hideSearch인데 검색창 노출 | + +## 수정 내용 + +### 1. UniversalListPage/index.tsx (핵심 템플릿) + +**searchFilter 지원 추가** - 서버사이드 모드(`clientSideFiltering: false`)에서도 클라이언트 검색 가능하도록 `config.searchFilter` 함수 지원. + +**fetchData API search 파라미터 버그 수정** - `useClientSearch` 모드일 때 API에 `search` 파라미터를 보내지 않도록 수정. API가 해당 필드 검색을 지원하지 않으면 0건 반환되어 클라이언트 필터링 자체가 불가능했음. + +```tsx +// 수정 전 (버그) +search: debouncedSearchValue, + +// 수정 후 +search: useClientSearch ? undefined : debouncedSearchValue, +``` + +**config.onSearchChange 호출 지원** - Pattern B 컴포넌트의 `config.onSearchChange`가 호출되도록 useEffect 추가. + +**hideSearch 완전 비활성화 로직** - `hideSearch: true`이면서 `onSearchChange`/`searchFilter`가 없는 컴포넌트는 검색을 완전 비활성화. 있는 컴포넌트는 기존대로 헤더 검색창 유지. + +```tsx +// hideSearch + onSearchChange/searchFilter 없음 → 검색 완전 숨김 (payment-history) +// hideSearch + onSearchChange/searchFilter 있음 → 헤더 검색창 표시 (approval/inbox) +searchValue={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : searchValue} +onSearchChange={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : handleSearchChange} +``` + +### 2. UniversalListPage/types.ts + +`searchFilter` 타입 정의 추가: `(item: T, searchValue: string) => boolean` + +### 3. 개별 컴포넌트 13개 + +각 페이지에 `searchFilter` 함수를 추가하여 어떤 필드를 검색 대상으로 할지 정의. + +## 검증 결과 + +15개 페이지 브라우저 직접 테스트 완료. + +- 검색 필터링: 전체 정상 동작 +- 검색창 포커스: 입력 시 포커스 유지 (리렌더링 없음) +- hideSearch 페이지: 검색창 정상 숨김 + +## 참고 + +- 빌드 시 `ReceivingDetail.tsx`에서 `SupplierSearchModal` 모듈 미발견 에러가 있으나 본 작업과 무관한 기존 이슈임. diff --git a/claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md b/claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md new file mode 100644 index 00000000..a10334bb --- /dev/null +++ b/claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md @@ -0,0 +1,192 @@ +# UniversalListPage 검색창 리렌더링 문제 해결 가이드 + +## 문제 현상 +- 검색창에 글자 하나만 입력해도 전체 페이지가 리렌더링됨 +- 검색어가 초기화되거나 데이터가 새로고침됨 +- 정상적인 검색이 불가능함 + +## 원인 분석 + +### 핵심 차이점: clientSideFiltering + +| 설정 | 동작 | 검색 시 fetchData 호출 | +|------|------|----------------------| +| `clientSideFiltering: true` | 클라이언트에서 필터링 | ❌ 호출 안함 | +| `clientSideFiltering: false` | 서버에서 필터링 | ✅ 매번 호출 | + +**UniversalListPage 내부 코드 (index.tsx:298-305):** +```javascript +useEffect(() => { + if (!config.clientSideFiltering && !isLoading && !isMobileLoading) { + fetchData(isMobileAppend); + } +}, [currentPage, searchValue, filters, activeTab, dateRangeKey]); +``` + +`clientSideFiltering: false`일 때 검색어(`searchValue`) 변경마다 `fetchData`가 호출됨. + +### 무한 루프 발생 조건 + +1. **getList 내부에서 setState 호출** + ```javascript + // ❌ 잘못된 패턴 + actions: { + getList: async (params) => { + const result = await getStocks(params); + if (result.success) { + setStockStats(result.data); // ← 상태 변경! + setTotalItems(result.pagination.total); // ← 상태 변경! + } + return result; + }, + }, + ``` + +2. **config가 useMemo로 감싸져 있고 상태 의존성이 있을 때** + - getList에서 setState → 컴포넌트 리렌더링 + - stats/tableFooter useMemo 재평가 + - config useMemo 재평가 (stats 의존성) + - UniversalListPage에 새 config 전달 + - dateRangeKey 재계산 → useEffect 트리거 + - fetchData 호출 → 무한 루프! + +## 해결 방법 + +### 방법 1: 수주관리 패턴 (권장) + +**클라이언트 사이드 필터링으로 전환** + +```typescript +// ===== 데이터 상태 (외부 관리) ===== +const [stocks, setStocks] = useState([]); +const [isLoading, setIsLoading] = useState(true); +const [searchTerm, setSearchTerm] = useState(''); +const [filterValues, setFilterValues] = useState>({}); + +// 데이터 로드 함수 +const loadData = useCallback(async () => { + setIsLoading(true); + const result = await getStocks({ page: 1, perPage: 9999 }); // 전체 로드 + if (result.success) { + setStocks(result.data); + } + setIsLoading(false); +}, [startDate, endDate]); + +// 클라이언트 사이드 필터링 +const filteredStocks = stocks.filter((stock) => { + if (searchTerm) { + const searchLower = searchTerm.toLowerCase(); + if (!stock.itemName.toLowerCase().includes(searchLower)) return false; + } + return true; +}); + +// config는 useMemo 없이 일반 객체로! +const config: UniversalListConfig = { + // ... + clientSideFiltering: true, // ← 핵심! + + actions: { + getList: async () => ({ + success: true, + data: filteredStocks, // ← 이미 필터링된 데이터 + totalCount: filteredStocks.length, + }), + }, + + searchFilter: (stock, searchValue) => { + return stock.itemName.toLowerCase().includes(searchValue.toLowerCase()); + }, + + customFilterFn: (items, fv) => { + // 필터 로직 + return items; + }, +}; + +return ( + +); +``` + +### 방법 2: 서버 사이드 필터링 유지 (주의 필요) + +**getList 내부에서 절대 setState 호출 금지** + +```typescript +// ✅ 올바른 패턴 +actions: { + getList: async (params) => { + const result = await getStocks(params); + if (result.success) { + // ❌ setStockStats, setTotalItems 호출 금지! + return { + success: true, + data: result.data, + totalCount: result.pagination.total, + totalPages: result.pagination.lastPage, + }; + } + return { success: false, error: result.error }; + }, +}, +``` + +**config useMemo 의존성 최소화** + +```typescript +// 상태에 의존하는 값들을 config 외부로 분리 +const config = useMemo(() => ({ + // 상태에 의존하지 않는 설정만 포함 + title: '목록', + idField: 'id', + clientSideFiltering: false, + // ... +}), []); // 빈 의존성 배열! + +// 상태에 의존하는 설정은 별도로 전달 +return ( + +); +``` + +## 체크리스트 + +### 문제 발생 시 확인 사항 + +- [ ] `clientSideFiltering` 값 확인 +- [ ] `getList` 내부에서 `setState` 호출 여부 +- [ ] config가 `useMemo`로 감싸져 있는지 +- [ ] useMemo 의존성에 상태값이 포함되어 있는지 +- [ ] `onSearchChange` 콜백이 상태를 업데이트하는지 + +### 권장 패턴 (수주관리 참고) + +``` +src/app/[locale]/(protected)/sales/order-management-sales/page.tsx +``` + +- `clientSideFiltering: true` +- config를 useMemo 없이 일반 객체로 정의 +- 외부에서 데이터 관리 (`useState`) +- `initialData` prop으로 데이터 전달 +- `onSearchChange`, `onFilterChange` 콜백 사용 + +## 관련 파일 + +- `src/components/templates/UniversalListPage/index.tsx` - useEffect 의존성 확인 (Line 298-305) +- `src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` - 정상 동작 패턴 참고 + +## 작성일 +2026-01-28 diff --git a/package-lock.json b/package-lock.json index f3fed8ba..d461bd7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8409,6 +8409,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx index 2251ac92..5084d5f4 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx @@ -416,6 +416,14 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) { ), searchPlaceholder: '제목, 작성자로 검색...', + searchFilter: (item: BoardPost, search: string) => { + const s = search.toLowerCase(); + return ( + item.title?.toLowerCase().includes(s) || + item.authorName?.toLowerCase().includes(s) || + false + ); + }, itemsPerPage: ITEMS_PER_PAGE, clientSideFiltering: true, diff --git a/src/components/accounting/BankTransactionInquiry/index.tsx b/src/components/accounting/BankTransactionInquiry/index.tsx index 0e26cc59..2d44d994 100644 --- a/src/components/accounting/BankTransactionInquiry/index.tsx +++ b/src/components/accounting/BankTransactionInquiry/index.tsx @@ -235,6 +235,16 @@ export function BankTransactionInquiry({ // 검색 searchPlaceholder: '은행명, 계좌명, 거래처, 입금자/수취인 검색...', onSearchChange: setSearchQuery, + searchFilter: (item: BankTransaction, search: string) => { + const s = search.toLowerCase(); + return ( + item.bankName?.toLowerCase().includes(s) || + item.accountName?.toLowerCase().includes(s) || + item.vendorName?.toLowerCase().includes(s) || + item.depositorName?.toLowerCase().includes(s) || + false + ); + }, // 필터 설정 (모바일용) filterConfig: [ diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index af8a0a2f..1cb766d7 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -384,6 +384,15 @@ export function BillManagementClient({ // 검색 searchPlaceholder: '어음번호, 거래처, 메모 검색...', onSearchChange: setSearchQuery, + searchFilter: (item: BillRecord, search: string) => { + const s = search.toLowerCase(); + return ( + item.billNumber?.toLowerCase().includes(s) || + item.vendorName?.toLowerCase().includes(s) || + item.note?.toLowerCase().includes(s) || + false + ); + }, // 모바일 필터 설정 filterConfig: [ diff --git a/src/components/accounting/BillManagement/index.tsx b/src/components/accounting/BillManagement/index.tsx index 5b7d83f4..bf178367 100644 --- a/src/components/accounting/BillManagement/index.tsx +++ b/src/components/accounting/BillManagement/index.tsx @@ -11,7 +11,7 @@ * - 삭제 기능 (deleteConfirmMessage) */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { getBills, deleteBill, updateBillStatus } from './actions'; @@ -80,6 +80,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [billData, setBillData] = useState([]); const [isLoading, setIsLoading] = useState(false); + const isInitialLoadDone = useRef(false); const [isSaving, setIsSaving] = useState(false); // 날짜 범위 @@ -103,7 +104,9 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem // ===== API에서 데이터 로드 ===== const loadBills = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const result = await getBills({ search: undefined, @@ -126,6 +129,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem toast.error('서버 오류가 발생했습니다.'); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [billTypeFilter, statusFilter, vendorFilter, startDate, endDate, currentPage]); diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 76403490..e204d81f 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -14,7 +14,7 @@ * - 계정과목명 일괄 저장 다이얼로그 */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; import { CreditCard, Plus, RefreshCw, Save, Loader2, Search } from 'lucide-react'; @@ -112,6 +112,7 @@ export function CardTransactionInquiry({ const [cardFilter, setCardFilter] = useState('all'); const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); const [isLoading, setIsLoading] = useState(!initialData.length); + const isInitialLoadDone = useRef(false); const itemsPerPage = 20; // 상단 계정과목명 선택 (저장용) @@ -142,7 +143,9 @@ export function CardTransactionInquiry({ // ===== 데이터 로드 ===== const loadData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const sortMapping: Record = { latest: { sortBy: 'used_at', sortDir: 'desc' }, @@ -181,6 +184,7 @@ export function CardTransactionInquiry({ console.error('[CardTransactionInquiry] loadData error:', error); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [currentPage, startDate, endDate, searchQuery, sortOption]); diff --git a/src/components/accounting/ExpectedExpenseManagement/index.tsx b/src/components/accounting/ExpectedExpenseManagement/index.tsx index 1c97539c..3ddcfc7f 100644 --- a/src/components/accounting/ExpectedExpenseManagement/index.tsx +++ b/src/components/accounting/ExpectedExpenseManagement/index.tsx @@ -861,6 +861,15 @@ export function ExpectedExpenseManagement({ // 검색 searchPlaceholder: '거래처, 계정과목, 적요 검색...', onSearchChange: setSearchQuery, + searchFilter: (item: TableRowData, search: string) => { + const s = search.toLowerCase(); + return ( + item.vendorName?.toLowerCase().includes(s) || + item.accountSubject?.toLowerCase().includes(s) || + item.note?.toLowerCase().includes(s) || + false + ); + }, // 행 번호 숨기기 (커스텀 번호 사용) showRowNumber: false, diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx index 25b5d7c1..7e89fdd1 100644 --- a/src/components/accounting/PurchaseManagement/index.tsx +++ b/src/components/accounting/PurchaseManagement/index.tsx @@ -14,7 +14,7 @@ * - deleteConfirmMessage로 삭제 다이얼로그 처리 */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { @@ -87,6 +87,7 @@ export function PurchaseManagement() { const [endDate, setEndDate] = useState('2025-12-31'); const [purchaseData, setPurchaseData] = useState([]); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); const [searchQuery, setSearchQuery] = useState(''); // 통합 필터 상태 (filterConfig 기반) @@ -105,7 +106,9 @@ export function PurchaseManagement() { // ===== API 데이터 로드 ===== useEffect(() => { const loadData = async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const result = await getPurchases({ startDate, @@ -123,6 +126,7 @@ export function PurchaseManagement() { setPurchaseData([]); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }; loadData(); diff --git a/src/components/accounting/VendorLedger/index.tsx b/src/components/accounting/VendorLedger/index.tsx index 868139ab..4ee03d3e 100644 --- a/src/components/accounting/VendorLedger/index.tsx +++ b/src/components/accounting/VendorLedger/index.tsx @@ -202,6 +202,13 @@ export function VendorLedger({ // 검색 searchPlaceholder: '거래처명 검색...', onSearchChange: setSearchQuery, + searchFilter: (item: VendorLedgerItem, search: string) => { + const s = search.toLowerCase(); + return ( + item.vendorName?.toLowerCase().includes(s) || + false + ); + }, // 날짜 선택기 dateRangeSelector: { diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 73012c9a..df9f0a6d 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect, useTransition } from 'react'; +import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { FileCheck, @@ -118,13 +118,16 @@ export function ApprovalBox() { const [totalCount, setTotalCount] = useState(0); const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); // 통계 데이터 const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 }); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => { switch (sortOption) { @@ -159,6 +162,7 @@ export function ApprovalBox() { toast.error('결재함 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]); @@ -525,6 +529,15 @@ export function ApprovalBox() { }, searchPlaceholder: '제목, 기안자, 부서 검색...', + searchFilter: (item: ApprovalRecord, search: string) => { + const s = search.toLowerCase(); + return ( + item.title?.toLowerCase().includes(s) || + item.drafter?.toLowerCase().includes(s) || + item.drafterDepartment?.toLowerCase().includes(s) || + false + ); + }, itemsPerPage: itemsPerPage, diff --git a/src/components/approval/DraftBox/index.tsx b/src/components/approval/DraftBox/index.tsx index 0209b50c..1b24bd64 100644 --- a/src/components/approval/DraftBox/index.tsx +++ b/src/components/approval/DraftBox/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect, useTransition } from 'react'; +import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, @@ -89,6 +89,7 @@ export function DraftBox() { const [totalCount, setTotalCount] = useState(0); const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); // 통계 데이터 const [summary, setSummary] = useState(null); @@ -99,7 +100,9 @@ export function DraftBox() { // ===== 데이터 로드 ===== const loadData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => { switch (sortOption) { @@ -133,6 +136,7 @@ export function DraftBox() { toast.error('기안함 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption]); diff --git a/src/components/approval/ReferenceBox/index.tsx b/src/components/approval/ReferenceBox/index.tsx index 6c635628..b46f6ae4 100644 --- a/src/components/approval/ReferenceBox/index.tsx +++ b/src/components/approval/ReferenceBox/index.tsx @@ -90,13 +90,16 @@ export function ReferenceBox() { const [totalCount, setTotalCount] = useState(0); const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); // 통계 데이터 const [summary, setSummary] = useState(null); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { // 정렬 옵션 변환 const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => { @@ -130,6 +133,7 @@ export function ReferenceBox() { toast.error('참조함 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]); @@ -460,6 +464,15 @@ export function ReferenceBox() { }, searchPlaceholder: '제목, 기안자, 부서 검색...', + searchFilter: (item: ReferenceRecord, search: string) => { + const s = search.toLowerCase(); + return ( + item.title?.toLowerCase().includes(s) || + item.drafter?.toLowerCase().includes(s) || + item.drafterDepartment?.toLowerCase().includes(s) || + false + ); + }, itemsPerPage: itemsPerPage, diff --git a/src/components/board/BoardList/index.tsx b/src/components/board/BoardList/index.tsx index 178de317..eb519b67 100644 --- a/src/components/board/BoardList/index.tsx +++ b/src/components/board/BoardList/index.tsx @@ -9,7 +9,7 @@ * - 테이블 컬럼: No., 제목, 작성자, 등록일, 조회수 */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; import { FileText, Plus, Pencil, Trash2 } from 'lucide-react'; @@ -53,6 +53,7 @@ export function BoardList() { const [totalItems, setTotalItems] = useState(0); const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); const [currentUserId, setCurrentUserId] = useState(''); const [searchQuery, setSearchQuery] = useState(''); @@ -75,7 +76,9 @@ export function BoardList() { async function fetchPosts() { if (!activeTab) return; - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { let result; @@ -113,6 +116,7 @@ export function BoardList() { setPosts([]); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } } diff --git a/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx b/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx index 2e0df114..89f681e9 100644 --- a/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx +++ b/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx @@ -166,8 +166,16 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin clientSideFiltering: true, itemsPerPage: 20, - // 검색 플레이스홀더 + // 검색 searchPlaceholder: '현장번호, 거래처, 현장명 검색', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + (item.briefingCode || '').toLowerCase().includes(search) || + (item.partnerName || '').toLowerCase().includes(search) || + (item.title || '').toLowerCase().includes(search) + ); + }, // 필터 설정 filterConfig: [ diff --git a/src/components/hr/AttendanceManagement/index.tsx b/src/components/hr/AttendanceManagement/index.tsx index 549149ab..d7a3dff0 100644 --- a/src/components/hr/AttendanceManagement/index.tsx +++ b/src/components/hr/AttendanceManagement/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { Clock, @@ -62,6 +62,7 @@ export function AttendanceManagement() { const [attendanceRecords, setAttendanceRecords] = useState([]); const [employees, setEmployees] = useState([]); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); const [total, setTotal] = useState(0); // 검색 및 필터 상태 @@ -89,7 +90,9 @@ export function AttendanceManagement() { // 데이터 로드 useEffect(() => { const fetchData = async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { // 사원 목록과 근태 목록 병렬 조회 const [employeesResult, attendancesResult] = await Promise.all([ @@ -109,6 +112,7 @@ export function AttendanceManagement() { console.error('[AttendanceManagement] fetchData error:', error); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }; fetchData(); diff --git a/src/components/hr/CardManagement/index.tsx b/src/components/hr/CardManagement/index.tsx index bf932817..be31e7cc 100644 --- a/src/components/hr/CardManagement/index.tsx +++ b/src/components/hr/CardManagement/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { CreditCard, Edit, Trash2, Plus, Search, RefreshCw } from 'lucide-react'; import { Input } from '@/components/ui/input'; @@ -39,6 +39,7 @@ export function CardManagement({ initialData }: CardManagementProps) { // 카드 데이터 상태 const [cards, setCards] = useState(initialData || []); const [isLoading, setIsLoading] = useState(!initialData); + const isInitialLoadDone = useRef(false); // 데이터 로드 useEffect(() => { @@ -48,7 +49,9 @@ export function CardManagement({ initialData }: CardManagementProps) { }, [initialData]); const loadCards = async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } const result = await getCards({ per_page: 100 }); if (result.success && result.data) { setCards(result.data); @@ -56,6 +59,7 @@ export function CardManagement({ initialData }: CardManagementProps) { toast.error(result.error || '카드 목록을 불러오는데 실패했습니다.'); } setIsLoading(false); + isInitialLoadDone.current = true; }; // 검색 및 필터 상태 diff --git a/src/components/hr/EmployeeManagement/index.tsx b/src/components/hr/EmployeeManagement/index.tsx index 41f4b80e..42ef7217 100644 --- a/src/components/hr/EmployeeManagement/index.tsx +++ b/src/components/hr/EmployeeManagement/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2, Search } from 'lucide-react'; import { getEmployees, deleteEmployee, deleteEmployees, getEmployeeStats } from './actions'; @@ -68,6 +68,7 @@ export function EmployeeManagement() { // 사원 데이터 상태 const [employees, setEmployees] = useState([]); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); const [total, setTotal] = useState(0); // 검색 및 필터 상태 @@ -96,7 +97,9 @@ export function EmployeeManagement() { // 데이터 로드 useEffect(() => { const fetchEmployees = async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const result = await getEmployees({ per_page: 100, // 충분히 많은 데이터 로드 @@ -108,6 +111,7 @@ export function EmployeeManagement() { console.error('[EmployeeManagement] fetchEmployees error:', error); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }; fetchEmployees(); diff --git a/src/components/hr/SalaryManagement/index.tsx b/src/components/hr/SalaryManagement/index.tsx index e2964e35..d4af1319 100644 --- a/src/components/hr/SalaryManagement/index.tsx +++ b/src/components/hr/SalaryManagement/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { Download, DollarSign, @@ -71,13 +71,16 @@ export function SalaryManagement() { // 데이터 상태 const [salaryData, setSalaryData] = useState([]); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); const [isActionLoading, setIsActionLoading] = useState(false); const [totalCount, setTotalCount] = useState(0); const [totalPages, setTotalPages] = useState(1); // ===== 데이터 로드 ===== const loadSalaries = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const result = await getSalaries({ search: searchQuery || undefined, @@ -100,6 +103,7 @@ export function SalaryManagement() { toast.error('급여 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [searchQuery, startDate, endDate, currentPage, itemsPerPage]); diff --git a/src/components/hr/VacationManagement/index.tsx b/src/components/hr/VacationManagement/index.tsx index 7f9e05cd..ec9ee60e 100644 --- a/src/components/hr/VacationManagement/index.tsx +++ b/src/components/hr/VacationManagement/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { format } from 'date-fns'; import { Plus, @@ -113,6 +113,7 @@ export function VacationManagement() { // 로딩/처리중 상태 const [isLoading, setIsLoading] = useState(false); + const isInitialLoadDone = useRef(false); const [isProcessing, setIsProcessing] = useState(false); // 데이터 상태 (usage/grant 탭은 API, request는 Mock) @@ -126,7 +127,9 @@ export function VacationManagement() { * 휴가 사용현황 데이터 로드 (usage 탭) */ const fetchUsageData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const currentYear = new Date().getFullYear(); const result = await getLeaveBalances({ year: currentYear, perPage: 100 }); @@ -159,6 +162,7 @@ export function VacationManagement() { console.error('[VacationManagement] fetchUsageData error:', error); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, []); @@ -166,7 +170,9 @@ export function VacationManagement() { * 휴가 부여현황 데이터 로드 (grant 탭) */ const fetchGrantData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const currentYear = new Date().getFullYear(); const result = await getLeaveGrants({ year: currentYear, perPage: 100 }); @@ -194,6 +200,7 @@ export function VacationManagement() { console.error('[VacationManagement] fetchGrantData error:', error); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, []); @@ -201,7 +208,9 @@ export function VacationManagement() { * 휴가 신청현황 데이터 로드 (request 탭) */ const fetchLeaveRequests = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } try { const result = await getLeaves({ dateFrom: startDate, @@ -237,6 +246,7 @@ export function VacationManagement() { console.error('[VacationManagement] fetchLeaveRequests error:', error); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, [startDate, endDate]); diff --git a/src/components/material/ReceivingManagement/ReceivingList.tsx b/src/components/material/ReceivingManagement/ReceivingList.tsx index 4ae6d8ac..2d22c127 100644 --- a/src/components/material/ReceivingManagement/ReceivingList.tsx +++ b/src/components/material/ReceivingManagement/ReceivingList.tsx @@ -87,7 +87,7 @@ export function ReceivingList() { // ===== 입고 등록 핸들러 ===== const handleRegister = useCallback(() => { - router.push('/ko/material/receiving-management/new'); + router.push('/ko/material/receiving-management/new?mode=new'); }, [router]); // ===== 통계 카드 ===== @@ -223,6 +223,15 @@ export function ReceivingList() { // 검색 searchPlaceholder: '로트번호, 품목코드, 품목명 검색...', + searchFilter: (item: ReceivingItem, search: string) => { + const s = search.toLowerCase(); + return ( + item.lotNo?.toLowerCase().includes(s) || + item.itemCode?.toLowerCase().includes(s) || + item.itemName?.toLowerCase().includes(s) || + false + ); + }, // 날짜 범위 필터 dateRangeSelector: { diff --git a/src/components/outbound/ShipmentManagement/ShipmentList.tsx b/src/components/outbound/ShipmentManagement/ShipmentList.tsx index f377e68c..293a9373 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentList.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentList.tsx @@ -232,6 +232,16 @@ export function ShipmentList() { // 검색 searchPlaceholder: '출고번호, 로트번호, 발주처, 현장명 검색...', + searchFilter: (item: ShipmentItem, search: string) => { + const s = search.toLowerCase(); + return ( + item.shipmentNo?.toLowerCase().includes(s) || + item.lotNo?.toLowerCase().includes(s) || + item.customerName?.toLowerCase().includes(s) || + item.siteName?.toLowerCase().includes(s) || + false + ); + }, // 탭 설정 tabs, diff --git a/src/components/pricing/PricingListClient.tsx b/src/components/pricing/PricingListClient.tsx index 5fc2b29d..08e3afd7 100644 --- a/src/components/pricing/PricingListClient.tsx +++ b/src/components/pricing/PricingListClient.tsx @@ -56,8 +56,8 @@ export function PricingListClient({ const searchFilter = (item: PricingListItem, search: string) => { const searchLower = search.toLowerCase(); return ( - item.itemCode.toLowerCase().includes(searchLower) || - item.itemName.toLowerCase().includes(searchLower) || + (item.itemCode?.toLowerCase().includes(searchLower) ?? false) || + (item.itemName?.toLowerCase().includes(searchLower) ?? false) || (item.specification?.toLowerCase().includes(searchLower) ?? false) ); }; @@ -75,8 +75,8 @@ export function PricingListClient({ if (searchTerm) { const search = searchTerm.toLowerCase(); result = result.filter(item => - item.itemCode.toLowerCase().includes(search) || - item.itemName.toLowerCase().includes(search) || + (item.itemCode?.toLowerCase().includes(search) ?? false) || + (item.itemName?.toLowerCase().includes(search) ?? false) || (item.specification?.toLowerCase().includes(search) ?? false) ); } diff --git a/src/components/production/WorkOrders/WorkOrderList.tsx b/src/components/production/WorkOrders/WorkOrderList.tsx index c3692d13..88ab421a 100644 --- a/src/components/production/WorkOrders/WorkOrderList.tsx +++ b/src/components/production/WorkOrders/WorkOrderList.tsx @@ -120,7 +120,7 @@ export function WorkOrderList() { iconColor: 'text-gray-600', }, { - label: '작업대기', + label: '미착수', value: statsData.waiting + statsData.unassigned + statsData.pending, icon: Calendar, iconColor: 'text-orange-600', @@ -209,6 +209,16 @@ export function WorkOrderList() { // 검색 searchPlaceholder: '작업지시번호, 발주처, 현장명 검색...', + searchFilter: (item: WorkOrder, search: string) => { + const s = search.toLowerCase(); + return ( + item.workOrderNo?.toLowerCase().includes(s) || + item.client?.toLowerCase().includes(s) || + item.projectName?.toLowerCase().includes(s) || + item.lotNo?.toLowerCase().includes(s) || + false + ); + }, // 탭 설정 tabs, diff --git a/src/components/production/WorkResults/WorkResultList.tsx b/src/components/production/WorkResults/WorkResultList.tsx index 0b32d830..2116ea0a 100644 --- a/src/components/production/WorkResults/WorkResultList.tsx +++ b/src/components/production/WorkResults/WorkResultList.tsx @@ -225,6 +225,15 @@ export function WorkResultList() { // 검색 searchPlaceholder: '로트번호, 작업지시번호, 품목명 검색...', + searchFilter: (item: WorkResult, search: string) => { + const s = search.toLowerCase(); + return ( + item.lotNo?.toLowerCase().includes(s) || + item.workOrderNo?.toLowerCase().includes(s) || + item.productName?.toLowerCase().includes(s) || + false + ); + }, // 통계 카드 stats, diff --git a/src/components/settings/AccountManagement/index.tsx b/src/components/settings/AccountManagement/index.tsx index 9eb00466..07773455 100644 --- a/src/components/settings/AccountManagement/index.tsx +++ b/src/components/settings/AccountManagement/index.tsx @@ -203,6 +203,16 @@ export function AccountManagement() { // 검색 searchPlaceholder: '은행명, 계좌번호, 계좌명, 예금주 검색...', + searchFilter: (item: Account, search: string) => { + const s = search.toLowerCase(); + return ( + item.bankName?.toLowerCase().includes(s) || + item.accountNumber?.toLowerCase().includes(s) || + item.accountName?.toLowerCase().includes(s) || + item.accountHolder?.toLowerCase().includes(s) || + false + ); + }, // 헤더 액션 headerActions: () => ( diff --git a/src/components/settings/PermissionManagement/index.tsx b/src/components/settings/PermissionManagement/index.tsx index a0a7a6fa..6e0b41a5 100644 --- a/src/components/settings/PermissionManagement/index.tsx +++ b/src/components/settings/PermissionManagement/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; import { @@ -42,6 +42,7 @@ export function PermissionManagement() { const [roles, setRoles] = useState([]); const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); const [error, setError] = useState(null); // 삭제 확인 다이얼로그 @@ -52,7 +53,9 @@ export function PermissionManagement() { // API에서 데이터 로드 const loadData = useCallback(async () => { - setIsLoading(true); + if (!isInitialLoadDone.current) { + setIsLoading(true); + } setError(null); try { @@ -74,6 +77,7 @@ export function PermissionManagement() { setError(err instanceof Error ? err.message : '데이터 로드 실패'); } finally { setIsLoading(false); + isInitialLoadDone.current = true; } }, []); diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx index 89694d3a..d8ef818b 100644 --- a/src/components/templates/IntegratedListTemplateV2.tsx +++ b/src/components/templates/IntegratedListTemplateV2.tsx @@ -543,7 +543,6 @@ export function IntegratedListTemplateV2({ {/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */} {/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */} {(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && ( - isLoading ? renderHeaderActionSkeleton() : (
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */} {dateRangeSelector?.enabled ? ( @@ -608,7 +607,6 @@ export function IntegratedListTemplateV2({
)} - ) )} {/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */} @@ -619,11 +617,7 @@ export function IntegratedListTemplateV2({ )} {/* 통계 카드 - 태블릿/데스크톱 */} - {isLoading && stats !== undefined ? ( -
- -
- ) : stats && stats.length > 0 ? ( + {stats && stats.length > 0 ? (
@@ -644,20 +638,13 @@ export function IntegratedListTemplateV2({ {!hideSearch && ( - {isLoading ? ( -
-
-
-
- ) : ( - {})} - searchPlaceholder={searchPlaceholder} - filterButton={false} - extraActions={extraFilters} - /> - )} + {})} + searchPlaceholder={searchPlaceholder} + filterButton={false} + extraActions={extraFilters} + /> )} diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index 1d9df35f..dbfea19d 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -11,7 +11,7 @@ * - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true) */ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { toast } from 'sonner'; import { Download, Loader2 } from 'lucide-react'; @@ -83,6 +83,9 @@ export function UniversalListPage({ // 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시) const [isMobileLoading, setIsMobileLoading] = useState(false); + // 초기 데이터 로딩 완료 여부 (검색/필터 변경 시 전체 스켈레톤 방지) + const isInitialFetchDone = useRef(false); + // 서버 사이드 페이지네이션 상태 (API에서 반환하는 값) const [serverTotalCount, setServerTotalCount] = useState(initialTotalCount || 0); const [serverTotalPages, setServerTotalPages] = useState(1); @@ -91,6 +94,8 @@ export function UniversalListPage({ useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchValue(searchValue); + // 검색 변경 시 페이지를 1로 리셋 (서버 사이드 페이지네이션에서 올바른 결과 보장) + setCurrentPage(1); }, 300); return () => clearTimeout(timer); @@ -107,9 +112,18 @@ export function UniversalListPage({ [config.idField] ); - // ===== 클라이언트 사이드 필터링 ===== + // ===== 데이터 필터링 ===== + // 서버 사이드 모드에서 searchFilter를 통한 클라이언트 사이드 검색 활성화 여부 + const isServerSearchFiltered = !config.clientSideFiltering && !!debouncedSearchValue && !!config.searchFilter; + const filteredData = useMemo(() => { if (!config.clientSideFiltering) { + // 서버 사이드 모드: searchFilter가 정의되어 있으면 클라이언트 사이드 검색 적용 (백엔드 검색 미지원 대비) + if (debouncedSearchValue && config.searchFilter) { + return rawData.filter((item) => + config.searchFilter!(item, debouncedSearchValue) + ); + } return rawData; } @@ -180,25 +194,32 @@ export function UniversalListPage({ return filtered; }, [rawData, activeTab, debouncedSearchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]); - // 클라이언트 사이드 페이지네이션 + // 페이지네이션 (클라이언트 사이드 + 서버 사이드 검색 시) const paginatedData = useMemo(() => { if (!config.clientSideFiltering) { + // 서버 사이드 검색 시 클라이언트 사이드 페이지네이션 적용 + if (debouncedSearchValue && config.searchFilter) { + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredData.slice(startIndex, startIndex + itemsPerPage); + } return rawData; } const startIndex = (currentPage - 1) * itemsPerPage; return filteredData.slice(startIndex, startIndex + itemsPerPage); - }, [config.clientSideFiltering, filteredData, currentPage, itemsPerPage, rawData]); + }, [config.clientSideFiltering, config.searchFilter, debouncedSearchValue, filteredData, currentPage, itemsPerPage, rawData]); // 총 개수 및 페이지 수 // 서버 사이드 페이지네이션: API에서 반환한 값 사용 // 클라이언트 사이드 페이지네이션: 로컬 데이터 길이 사용 - const totalCount = config.clientSideFiltering ? filteredData.length : serverTotalCount; + const totalCount = config.clientSideFiltering + ? filteredData.length + : (isServerSearchFiltered ? filteredData.length : serverTotalCount); const totalPages = config.clientSideFiltering ? Math.ceil(totalCount / itemsPerPage) - : serverTotalPages; + : (isServerSearchFiltered ? (Math.ceil(filteredData.length / itemsPerPage) || 1) : serverTotalPages); // 표시할 데이터 - const displayData = config.clientSideFiltering ? paginatedData : rawData; + const displayData = (config.clientSideFiltering || isServerSearchFiltered) ? paginatedData : rawData; // ===== 탭 카운트 계산 (클라이언트 사이드) ===== const computedTabs = useMemo(() => { @@ -221,18 +242,21 @@ export function UniversalListPage({ // 모바일 추가 로드면 isMobileLoading, 그 외에는 isLoading if (isMobileAppend) { setIsMobileLoading(true); - } else { + } else if (!isInitialFetchDone.current) { + // 초기 로딩 시에만 전체 스켈레톤 표시 setIsLoading(true); } try { + // 서버 사이드 + searchFilter 정의 + 검색 중: 전체 데이터를 받아서 클라이언트 사이드 필터링 + const useClientSearch = !config.clientSideFiltering && !!config.searchFilter && !!debouncedSearchValue; const result = await config.actions.getList( config.clientSideFiltering ? { pageSize: 9999 } // 클라이언트 사이드: 전체 데이터 로드 : { - page: currentPage, - pageSize: itemsPerPage, - search: debouncedSearchValue, + page: useClientSearch ? 1 : currentPage, + pageSize: useClientSearch ? 9999 : itemsPerPage, + search: useClientSearch ? undefined : debouncedSearchValue, filters, tab: activeTab, } @@ -258,8 +282,9 @@ export function UniversalListPage({ } finally { setIsLoading(false); setIsMobileLoading(false); + isInitialFetchDone.current = true; } - }, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, debouncedSearchValue, filters, activeTab]); + }, [config.actions, config.clientSideFiltering, config.searchFilter, currentPage, itemsPerPage, debouncedSearchValue, filters, activeTab]); // 초기 로딩 (initialData가 없거나 빈 배열인 경우) useEffect(() => { @@ -306,7 +331,7 @@ export function UniversalListPage({ : ''; useEffect(() => { - if (!config.clientSideFiltering && !isLoading && !isMobileLoading) { + if (!config.clientSideFiltering && !externalPagination && !isLoading && !isMobileLoading) { // 페이지가 증가하는 경우 = 모바일 인피니티 스크롤 const isMobileAppend = currentPage > prevPage && currentPage > 1; fetchData(isMobileAppend); @@ -480,8 +505,12 @@ export function UniversalListPage({ }, []); // 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용) + // config.onSearchChange: config 내부에서 설정한 검색 콜백도 호출 + // ⚠️ config.onSearchChange는 deps에서 제외 (config 재생성 → 무한 루프 방지, config.onDataChange 패턴 참고) useEffect(() => { onSearchChange?.(debouncedSearchValue); + config.onSearchChange?.(debouncedSearchValue); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchValue, onSearchChange]); // ===== 필터 핸들러 ===== @@ -723,15 +752,27 @@ export function UniversalListPage({ // ===== 페이지네이션 config ===== // 외부 페이지네이션 사용 시 외부 설정 사용 + // 단, 서버 사이드 검색 모드(searchFilter)에서는 필터링된 데이터 기준으로 재계산 const paginationConfig: PaginationConfig = useMemo( - () => externalPagination ?? { - currentPage, - totalPages, - totalItems: totalCount, - itemsPerPage, - onPageChange: handlePageChange, + () => { + if (isServerSearchFiltered && externalPagination) { + return { + ...externalPagination, + currentPage, + totalPages: Math.ceil(filteredData.length / itemsPerPage) || 1, + totalItems: filteredData.length, + onPageChange: handlePageChange, + }; + } + return externalPagination ?? { + currentPage, + totalPages, + totalItems: totalCount, + itemsPerPage, + onPageChange: handlePageChange, + }; }, - [externalPagination, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange] + [externalPagination, isServerSearchFiltered, filteredData.length, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange] ); // ===== 렌더링 함수 래퍼 ===== @@ -801,8 +842,10 @@ export function UniversalListPage({ // 경고 배너 alertBanner={config.alertBanner} // 검색 및 필터 - searchValue={searchValue} - onSearchChange={handleSearchChange} + // hideSearch: true이면서 config에 onSearchChange/searchFilter가 없으면 검색 완전 비활성화 + // hideSearch: true이면서 onSearchChange/searchFilter가 있으면 헤더 검색창만 표시 (Card SearchFilter 숨김) + searchValue={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : searchValue} + onSearchChange={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : handleSearchChange} searchPlaceholder={config.searchPlaceholder} extraFilters={config.extraFilters} hideSearch={config.hideSearch} @@ -829,7 +872,7 @@ export function UniversalListPage({ tableHeaderActions={ typeof config.tableHeaderActions === 'function' ? config.tableHeaderActions({ - totalCount: externalPagination?.totalItems ?? totalCount, + totalCount: isServerSearchFiltered ? filteredData.length : (externalPagination?.totalItems ?? totalCount), selectedItems: effectiveSelectedItems, onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()), }) diff --git a/src/components/templates/UniversalListPage/types.ts b/src/components/templates/UniversalListPage/types.ts index 363c26b0..d1da9441 100644 --- a/src/components/templates/UniversalListPage/types.ts +++ b/src/components/templates/UniversalListPage/types.ts @@ -344,7 +344,10 @@ export interface UniversalListConfig { * true인 경우 getList가 전체 데이터를 반환하고, 컴포넌트 내부에서 필터링/페이지네이션 처리 */ clientSideFiltering?: boolean; - /** 클라이언트 사이드 검색 필터 함수 */ + /** 검색 필터 함수 (클라이언트 사이드 + 서버 사이드 모드 모두 지원) + * - clientSideFiltering: true → 기존 클라이언트 사이드 검색 + * - clientSideFiltering: false → 백엔드 검색 미지원 시 클라이언트 사이드 fallback 검색 + */ searchFilter?: (item: T, searchValue: string) => boolean; /** 클라이언트 사이드 탭 필터 함수 */ tabFilter?: (item: T, activeTab: string) => boolean;