feat(WEB): 입력 컴포넌트 공통화 및 UI 개선
- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가 - MobileCard 컴포넌트 통합 (ListMobileCard 제거) - IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈) - IntegratedDetailTemplate 타이틀 중복 수정 - 문서 시스템 컴포넌트 추가 - 헤더 벨 아이콘 포커스 스타일 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,16 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
|
||||
import { CardNumberInput } from '@/components/ui/card-number-input';
|
||||
import { AccountNumberInput } from '@/components/ui/account-number-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatPhoneNumber, formatBusinessNumber, formatCardNumber, formatAccountNumber, formatNumber } from '@/lib/formatters';
|
||||
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -107,6 +116,32 @@ function renderViewValue(
|
||||
<div className="whitespace-pre-wrap">{String(value)}</div>
|
||||
);
|
||||
|
||||
case 'phone':
|
||||
return formatPhoneNumber(String(value));
|
||||
|
||||
case 'businessNumber':
|
||||
return formatBusinessNumber(String(value));
|
||||
|
||||
case 'cardNumber':
|
||||
return formatCardNumber(String(value));
|
||||
|
||||
case 'accountNumber':
|
||||
return formatAccountNumber(String(value));
|
||||
|
||||
case 'personalNumber': {
|
||||
const pn = String(value).replace(/\D/g, '');
|
||||
if (pn.length === 13) {
|
||||
return `${pn.slice(0, 6)}-${pn.slice(6, 7)}******`;
|
||||
}
|
||||
return pn;
|
||||
}
|
||||
|
||||
case 'currency':
|
||||
return `₩ ${formatNumber(Number(value) || 0, { useComma: true })}`;
|
||||
|
||||
case 'quantity':
|
||||
return formatNumber(Number(value) || 0, { useComma: true });
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
@@ -256,6 +291,86 @@ function renderFormField(
|
||||
/>
|
||||
);
|
||||
|
||||
case 'phone':
|
||||
return (
|
||||
<PhoneInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'businessNumber':
|
||||
return (
|
||||
<BusinessNumberInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
showValidation
|
||||
/>
|
||||
);
|
||||
|
||||
case 'personalNumber':
|
||||
return (
|
||||
<PersonalNumberInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
maskBack
|
||||
/>
|
||||
);
|
||||
|
||||
case 'cardNumber':
|
||||
return (
|
||||
<CardNumberInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder || '0000-0000-0000-0000'}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'accountNumber':
|
||||
return (
|
||||
<AccountNumberInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder || '0000-0000-0000-0000'}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'currency':
|
||||
return (
|
||||
<CurrencyInput
|
||||
value={value as number | undefined}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'quantity':
|
||||
return (
|
||||
<QuantityInput
|
||||
value={value as number | undefined}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
min={0}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
if (field.renderField) {
|
||||
return field.renderField({
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatPhoneNumber, formatBusinessNumber, formatNumber } from '@/lib/formatters';
|
||||
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -123,6 +129,26 @@ function renderViewValue(
|
||||
<div className="whitespace-pre-wrap">{String(value)}</div>
|
||||
);
|
||||
|
||||
case 'phone':
|
||||
return formatPhoneNumber(String(value));
|
||||
|
||||
case 'businessNumber':
|
||||
return formatBusinessNumber(String(value));
|
||||
|
||||
case 'personalNumber': {
|
||||
const pn = String(value).replace(/\D/g, '');
|
||||
if (pn.length === 13) {
|
||||
return `${pn.slice(0, 6)}-${pn.slice(6, 7)}******`;
|
||||
}
|
||||
return pn;
|
||||
}
|
||||
|
||||
case 'currency':
|
||||
return `₩ ${formatNumber(Number(value) || 0, { useComma: true })}`;
|
||||
|
||||
case 'quantity':
|
||||
return formatNumber(Number(value) || 0, { useComma: true });
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
@@ -271,6 +297,64 @@ function renderFormField(
|
||||
/>
|
||||
);
|
||||
|
||||
case 'phone':
|
||||
return (
|
||||
<PhoneInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={!!error}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'businessNumber':
|
||||
return (
|
||||
<BusinessNumberInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={!!error}
|
||||
showValidation
|
||||
/>
|
||||
);
|
||||
|
||||
case 'personalNumber':
|
||||
return (
|
||||
<PersonalNumberInput
|
||||
value={stringValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={!!error}
|
||||
maskBack
|
||||
/>
|
||||
);
|
||||
|
||||
case 'currency':
|
||||
return (
|
||||
<CurrencyInput
|
||||
value={value as number | undefined}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={!!error}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'quantity':
|
||||
return (
|
||||
<QuantityInput
|
||||
value={value as number | undefined}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
error={!!error}
|
||||
min={0}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
if (field.renderField) {
|
||||
return field.renderField({
|
||||
|
||||
@@ -25,7 +25,15 @@ export type FieldType =
|
||||
| 'dateRange'
|
||||
| 'richtext'
|
||||
| 'file'
|
||||
| 'custom';
|
||||
| 'custom'
|
||||
// 포맷팅 입력 타입
|
||||
| 'phone' // 전화번호 (자동 하이픈)
|
||||
| 'businessNumber' // 사업자번호 (000-00-00000)
|
||||
| 'personalNumber' // 주민번호 (000000-0000000)
|
||||
| 'cardNumber' // 카드번호 (0000-0000-0000-0000)
|
||||
| 'accountNumber' // 계좌번호 (4자리마다 하이픈)
|
||||
| 'currency' // 금액 (₩, 천단위 콤마)
|
||||
| 'quantity'; // 수량 (정수, 최소 0)
|
||||
|
||||
// ===== 옵션 (select, radio용) =====
|
||||
export interface FieldOption {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, Fragment, useState, RefObject } from "react";
|
||||
import { LucideIcon, Trash2, Plus } from "lucide-react";
|
||||
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -185,10 +185,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
// 데이터
|
||||
data: T[]; // 데스크톱용 페이지네이션된 데이터
|
||||
totalCount?: number; // 전체 데이터 개수 (역순 번호 계산용)
|
||||
allData?: T[]; // 모바일 인피니티 스크롤용 전체 필터된 데이터
|
||||
mobileDisplayCount?: number; // 모바일에서 표시할 개수
|
||||
onLoadMore?: () => void; // 더 불러오기 콜백
|
||||
infinityScrollSentinelRef?: RefObject<HTMLDivElement | null>; // 인피니티 스크롤용 sentinel ref
|
||||
allData?: T[]; // 클라이언트 사이드 필터링용 전체 데이터 (소량 데이터 페이지용)
|
||||
mobileDisplayCount?: number; // 클라이언트 사이드 인피니티에서 표시할 개수
|
||||
onLoadMore?: () => void; // 더 불러오기 콜백 (레거시)
|
||||
|
||||
// ===== 서버 사이드 모바일 인피니티 스크롤 =====
|
||||
// 모바일에서 스크롤/버튼으로 다음 페이지 로드, 데이터 누적 표시
|
||||
enableMobileInfinityScroll?: boolean; // 서버 사이드 인피니티 활성화 (기본: true)
|
||||
isMobileLoading?: boolean; // 모바일 추가 로딩 중 상태
|
||||
|
||||
// 체크박스 선택
|
||||
selectedItems: Set<string>;
|
||||
@@ -252,7 +256,8 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
allData,
|
||||
mobileDisplayCount,
|
||||
onLoadMore,
|
||||
infinityScrollSentinelRef,
|
||||
enableMobileInfinityScroll = true, // 기본값: 활성화
|
||||
isMobileLoading = false,
|
||||
selectedItems,
|
||||
onToggleSelection,
|
||||
onToggleSelectAll,
|
||||
@@ -269,9 +274,141 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// ===== 서버 사이드 모바일 인피니티 스크롤 =====
|
||||
// 모바일에서 누적 데이터를 관리하여 스크롤 시 계속 추가
|
||||
const [accumulatedMobileData, setAccumulatedMobileData] = useState<T[]>([]);
|
||||
const [lastAccumulatedPage, setLastAccumulatedPage] = useState(0);
|
||||
const mobileScrollSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 클라이언트 사이드 인피니티용 (allData가 있는 경우)
|
||||
const [clientDisplayCount, setClientDisplayCount] = useState(mobileDisplayCount || 20);
|
||||
|
||||
// 서버 페이지네이션: 데이터 누적 로직
|
||||
// - 페이지 1이면 리셋 (필터/탭 변경)
|
||||
// - 이전 페이지 + 1이면 누적 (스크롤로 다음 페이지 로드)
|
||||
//
|
||||
// 서버 사이드 판단: allData가 없거나, pagination.totalItems > allData.length
|
||||
// (외부 훅으로 데이터 관리하면서 서버 페이지네이션 사용하는 경우)
|
||||
useEffect(() => {
|
||||
const isServerSide = !allData || pagination.totalItems > allData.length;
|
||||
|
||||
if (!isServerSide) {
|
||||
// 순수 클라이언트 사이드 필터링 모드 - 누적 불필요
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enableMobileInfinityScroll) {
|
||||
// 서버 사이드 인피니티 비활성화 - data만 사용
|
||||
setAccumulatedMobileData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pagination.currentPage === 1) {
|
||||
// 페이지 1: 필터/탭 변경으로 리셋
|
||||
setAccumulatedMobileData(data);
|
||||
setLastAccumulatedPage(1);
|
||||
} else if (pagination.currentPage === lastAccumulatedPage + 1) {
|
||||
// 다음 페이지: 기존 데이터에 누적
|
||||
setAccumulatedMobileData(prev => [...prev, ...data]);
|
||||
setLastAccumulatedPage(pagination.currentPage);
|
||||
} else if (pagination.currentPage !== lastAccumulatedPage) {
|
||||
// 페이지 점프 (예: PC에서 페이지 변경 후 모바일로): 현재 데이터만 표시
|
||||
setAccumulatedMobileData(data);
|
||||
setLastAccumulatedPage(pagination.currentPage);
|
||||
}
|
||||
}, [data, pagination.currentPage, pagination.totalItems, allData, enableMobileInfinityScroll, lastAccumulatedPage]);
|
||||
|
||||
// 탭 변경 감지: activeTab 변경 시 누적 데이터 리셋
|
||||
// 주의: allData를 dependency에 넣으면 페이지 변경 시마다 리셋됨 (외부 훅 사용 시)
|
||||
useEffect(() => {
|
||||
if (enableMobileInfinityScroll) {
|
||||
setAccumulatedMobileData([]);
|
||||
setLastAccumulatedPage(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]); // activeTab만 감지 - 탭 변경 시에만 리셋
|
||||
|
||||
// 클라이언트 사이드: allData가 변경되면 displayCount 리셋
|
||||
useEffect(() => {
|
||||
if (allData) {
|
||||
setClientDisplayCount(mobileDisplayCount || 20);
|
||||
}
|
||||
}, [allData, mobileDisplayCount]);
|
||||
|
||||
// 서버 사이드: 스크롤/버튼으로 다음 페이지 로드
|
||||
const handleLoadMoreMobile = useCallback(() => {
|
||||
if (isMobileLoading) return;
|
||||
if (pagination.currentPage >= pagination.totalPages) return;
|
||||
|
||||
// 다음 페이지 요청
|
||||
pagination.onPageChange(pagination.currentPage + 1);
|
||||
}, [isMobileLoading, pagination]);
|
||||
|
||||
// 클라이언트 사이드: 더 보기
|
||||
const handleLoadMoreClient = useCallback(() => {
|
||||
if (!allData) return;
|
||||
setClientDisplayCount(prev => Math.min(prev + 20, allData.length));
|
||||
onLoadMore?.();
|
||||
}, [allData, onLoadMore]);
|
||||
|
||||
// 서버 사이드 페이지네이션 사용 여부 판단 (useEffect보다 먼저 정의)
|
||||
// - allData가 없으면 서버 사이드
|
||||
// - allData가 있어도 pagination.totalItems가 더 크면 서버 사이드 (외부 훅으로 데이터 관리하는 경우)
|
||||
const isServerSidePagination = !allData || pagination.totalItems > allData.length;
|
||||
|
||||
// Intersection Observer - 서버 사이드 & 클라이언트 사이드 공통
|
||||
useEffect(() => {
|
||||
if (!mobileScrollSentinelRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0].isIntersecting) return;
|
||||
|
||||
if (isServerSidePagination) {
|
||||
// 서버 사이드 인피니티
|
||||
if (enableMobileInfinityScroll && !isMobileLoading && pagination.currentPage < pagination.totalPages) {
|
||||
handleLoadMoreMobile();
|
||||
}
|
||||
} else {
|
||||
// 클라이언트 사이드 인피니티
|
||||
if (clientDisplayCount < allData!.length) {
|
||||
handleLoadMoreClient();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(mobileScrollSentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isServerSidePagination, allData, clientDisplayCount, enableMobileInfinityScroll, isMobileLoading, pagination.currentPage, pagination.totalPages, handleLoadMoreClient, handleLoadMoreMobile]);
|
||||
|
||||
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
|
||||
const allSelected = selectedItems.size === data.length && data.length > 0;
|
||||
|
||||
// 모바일용 데이터 결정
|
||||
// 1. 서버 사이드: 누적 데이터 또는 현재 페이지 데이터
|
||||
// 2. 클라이언트 사이드: allData에서 slice
|
||||
// 참고: accumulatedMobileData가 비어있으면 data 또는 allData를 폴백으로 사용
|
||||
const mobileData = isServerSidePagination
|
||||
? (enableMobileInfinityScroll
|
||||
? (accumulatedMobileData.length > 0 ? accumulatedMobileData : (allData || data))
|
||||
: data)
|
||||
: allData!.slice(0, clientDisplayCount);
|
||||
|
||||
// 더 로드 가능 여부
|
||||
const hasMoreData = isServerSidePagination
|
||||
? pagination.currentPage < pagination.totalPages
|
||||
: clientDisplayCount < allData!.length;
|
||||
|
||||
// 현재 로드된 개수 / 전체 개수
|
||||
const loadedCount = isServerSidePagination
|
||||
? (accumulatedMobileData.length > 0 ? accumulatedMobileData.length : mobileData.length)
|
||||
: clientDisplayCount;
|
||||
const totalDataCount = isServerSidePagination
|
||||
? pagination.totalItems
|
||||
: allData!.length;
|
||||
|
||||
// ===== filterConfig 기반 자동 필터 렌더링 =====
|
||||
// PC용 인라인 필터 (xl 이상에서 표시)
|
||||
const renderAutoFilters = () => {
|
||||
@@ -533,13 +670,13 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
|
||||
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
|
||||
<div className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
|
||||
{(allData && allData.length > 0 ? allData : data).length === 0 ? (
|
||||
{mobileData.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground border rounded-lg text-[14px]">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
// 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순)
|
||||
(allData || data).map((item, index) => {
|
||||
// 인피니티 스크롤: mobileData는 allData?.slice(0, displayCount) || data
|
||||
mobileData.map((item, index) => {
|
||||
const itemId = getItemId(item);
|
||||
const isSelected = selectedItems.has(itemId);
|
||||
// 순차 번호: 1번부터 시작
|
||||
@@ -558,13 +695,45 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* 인피니티 스크롤 Sentinel */}
|
||||
{infinityScrollSentinelRef && (
|
||||
<div
|
||||
ref={infinityScrollSentinelRef}
|
||||
className="h-10 w-full col-span-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* 모바일 인피니티 스크롤 - 더 보기 버튼 & 로딩 표시 */}
|
||||
{mobileData.length > 0 && (
|
||||
<div className="col-span-full">
|
||||
{hasMoreData ? (
|
||||
<>
|
||||
{/* 스크롤 감지용 Sentinel */}
|
||||
<div
|
||||
ref={mobileScrollSentinelRef}
|
||||
className="h-4 w-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* 더 보기 버튼 + 진행 상황 */}
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
{isMobileLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={isServerSidePagination ? handleLoadMoreMobile : handleLoadMoreClient}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
더 보기
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loadedCount.toLocaleString()} / {totalDataCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
모든 항목을 불러왔습니다 ({totalDataCount.toLocaleString()}개)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ export function UniversalListPage<T>({
|
||||
|
||||
const itemsPerPage = config.itemsPerPage || 20;
|
||||
|
||||
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
|
||||
const [isMobileLoading, setIsMobileLoading] = useState(false);
|
||||
|
||||
// ===== ID 추출 헬퍼 =====
|
||||
const getItemId = useCallback(
|
||||
(item: T): string => {
|
||||
@@ -152,8 +155,15 @@ export function UniversalListPage<T>({
|
||||
}, [config.clientSideFiltering, config.tabs, config.tabFilter, rawData, tabs]);
|
||||
|
||||
// ===== 데이터 로딩 =====
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
// isMobileAppend: 모바일 인피니티 스크롤로 추가 로드 시 true
|
||||
const fetchData = useCallback(async (isMobileAppend = false) => {
|
||||
// 모바일 추가 로드면 isMobileLoading, 그 외에는 isLoading
|
||||
if (isMobileAppend) {
|
||||
setIsMobileLoading(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await config.actions.getList(
|
||||
config.clientSideFiltering
|
||||
@@ -169,15 +179,15 @@ export function UniversalListPage<T>({
|
||||
|
||||
if (result.success && result.data) {
|
||||
setRawData(result.data);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UniversalListPage] Fetch error:', error);
|
||||
toast.error('데이터를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsMobileLoading(false);
|
||||
}
|
||||
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
|
||||
|
||||
@@ -208,9 +218,14 @@ export function UniversalListPage<T>({
|
||||
}, [rawData, config.onDataChange]);
|
||||
|
||||
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
|
||||
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
|
||||
const [prevPage, setPrevPage] = useState(1);
|
||||
useEffect(() => {
|
||||
if (!config.clientSideFiltering && !isLoading) {
|
||||
fetchData();
|
||||
if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
|
||||
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
|
||||
const isMobileAppend = currentPage > prevPage && currentPage > 1;
|
||||
fetchData(isMobileAppend);
|
||||
setPrevPage(currentPage);
|
||||
}
|
||||
}, [currentPage, searchValue, filters, activeTab]);
|
||||
|
||||
@@ -571,6 +586,8 @@ export function UniversalListPage<T>({
|
||||
data={displayData}
|
||||
totalCount={totalCount}
|
||||
allData={config.clientSideFiltering ? filteredData : undefined}
|
||||
// 모바일 인피니티 스크롤 로딩 상태
|
||||
isMobileLoading={isMobileLoading}
|
||||
// 체크박스 선택
|
||||
selectedItems={effectiveSelectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
|
||||
Reference in New Issue
Block a user