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:
80
claudedocs/guides/UniversalListPage-검색기능-수정내역.md
Normal file
80
claudedocs/guides/UniversalListPage-검색기능-수정내역.md
Normal 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` 모듈 미발견 에러가 있으나 본 작업과 무관한 기존 이슈임.
|
||||
192
claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md
Normal file
192
claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md
Normal 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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// 검색 및 필터 상태
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user