feat: UniversalListPage 검색 기능 개선 및 리렌더링 버그 수정

- UniversalListPage 템플릿에 searchFilter, useClientSearch 지원 추가
- 검색 입력 시 리렌더링(포커스 유실) 버그 수정
- 29개 리스트 페이지에 searchFilter 함수 추가
- SiteBriefingListClient 누락된 searchFilter 추가
- IntegratedListTemplateV2 검색 로직 정리
- 검색 기능 수정내역 가이드 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-29 14:50:45 +09:00
parent 099700758c
commit a5578bf669
31 changed files with 570 additions and 79 deletions

View File

@@ -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` 모듈 미발견 에러가 있으나 본 작업과 무관한 기존 이슈임.

View File

@@ -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<StockItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({});
// 데이터 로드 함수
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<StockItem> = {
// ...
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 (
<UniversalListPage
config={config}
initialData={filteredStocks}
initialTotalCount={filteredStocks.length}
onFilterChange={setFilterValues}
onSearchChange={setSearchTerm}
/>
);
```
### 방법 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 (
<UniversalListPage
config={config}
stats={stats} // 별도 prop으로 전달
tableFooter={tableFooter} // 별도 prop으로 전달
/>
);
```
## 체크리스트
### 문제 발생 시 확인 사항
- [ ] `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

11
package-lock.json generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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<BillRecord[]>([]);
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]);

View File

@@ -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<string>('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<SortOption, { sortBy: string; sortDir: 'asc' | 'desc' }> = {
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]);

View File

@@ -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,

View File

@@ -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<PurchaseRecord[]>([]);
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();

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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<DraftsSummary | null>(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]);

View File

@@ -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<ReferenceSummary | null>(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,

View File

@@ -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<string>('');
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;
}
}

View File

@@ -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: [

View File

@@ -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<AttendanceRecord[]>([]);
const [employees, setEmployees] = useState<EmployeeOption[]>([]);
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();

View File

@@ -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<Card[]>(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;
};
// 검색 및 필터 상태

View File

@@ -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<Employee[]>([]);
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();

View File

@@ -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<SalaryRecord[]>([]);
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]);

View File

@@ -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]);

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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)
);
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: () => (

View File

@@ -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<Role[]>([]);
const [stats, setStats] = useState<RoleStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isInitialLoadDone = useRef(false);
const [error, setError] = useState<string | null>(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;
}
}, []);

View File

@@ -543,7 +543,6 @@ export function IntegratedListTemplateV2<T = any>({
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
{/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
{(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
isLoading ? renderHeaderActionSkeleton() : (
<div className="flex flex-col xl:flex-row xl:flex-wrap xl:items-center xl:justify-between gap-2 w-full">
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */}
{dateRangeSelector?.enabled ? (
@@ -608,7 +607,6 @@ export function IntegratedListTemplateV2<T = any>({
</div>
)}
</div>
)
)}
{/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */}
@@ -619,11 +617,7 @@ export function IntegratedListTemplateV2<T = any>({
)}
{/* 통계 카드 - 태블릿/데스크톱 */}
{isLoading && stats !== undefined ? (
<div className="hidden md:block">
<StatCardGridSkeleton count={stats.length || 4} />
</div>
) : stats && stats.length > 0 ? (
{stats && stats.length > 0 ? (
<div className="hidden md:block">
<StatCards stats={stats} />
</div>
@@ -644,20 +638,13 @@ export function IntegratedListTemplateV2<T = any>({
{!hideSearch && (
<Card>
<CardContent className="p-6">
{isLoading ? (
<div className="flex flex-wrap gap-4">
<div className="h-10 w-64 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-10 w-32 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
</div>
) : (
<SearchFilter
searchValue={searchValue || ''}
onSearchChange={onSearchChange || (() => {})}
searchPlaceholder={searchPlaceholder}
filterButton={false}
extraActions={extraFilters}
/>
)}
<SearchFilter
searchValue={searchValue || ''}
onSearchChange={onSearchChange || (() => {})}
searchPlaceholder={searchPlaceholder}
filterButton={false}
extraActions={extraFilters}
/>
</CardContent>
</Card>
)}

View File

@@ -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<T>({
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
const [isMobileLoading, setIsMobileLoading] = useState(false);
// 초기 데이터 로딩 완료 여부 (검색/필터 변경 시 전체 스켈레톤 방지)
const isInitialFetchDone = useRef(false);
// 서버 사이드 페이지네이션 상태 (API에서 반환하는 값)
const [serverTotalCount, setServerTotalCount] = useState<number>(initialTotalCount || 0);
const [serverTotalPages, setServerTotalPages] = useState<number>(1);
@@ -91,6 +94,8 @@ export function UniversalListPage<T>({
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchValue(searchValue);
// 검색 변경 시 페이지를 1로 리셋 (서버 사이드 페이지네이션에서 올바른 결과 보장)
setCurrentPage(1);
}, 300);
return () => clearTimeout(timer);
@@ -107,9 +112,18 @@ export function UniversalListPage<T>({
[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<T>({
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<T>({
// 모바일 추가 로드면 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<T>({
} 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<T>({
: '';
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<T>({
}, []);
// 외부 콜백에 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<T>({
// ===== 페이지네이션 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<T>({
// 경고 배너
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<T>({
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()),
})

View File

@@ -344,7 +344,10 @@ export interface UniversalListConfig<T> {
* true인 경우 getList가 전체 데이터를 반환하고, 컴포넌트 내부에서 필터링/페이지네이션 처리
*/
clientSideFiltering?: boolean;
/** 클라이언트 사이드 검색 필터 함수 */
/** 검색 필터 함수 (클라이언트 사이드 + 서버 사이드 모드 모두 지원)
* - clientSideFiltering: true → 기존 클라이언트 사이드 검색
* - clientSideFiltering: false → 백엔드 검색 미지원 시 클라이언트 사이드 fallback 검색
*/
searchFilter?: (item: T, searchValue: string) => boolean;
/** 클라이언트 사이드 탭 필터 함수 */
tabFilter?: (item: T, activeTab: string) => boolean;