- useColumnSettings 훅: 컬럼 가시성 토글 로직 - useTableColumnStore: Zustand 기반 컬럼 설정 영속화 (localStorage) - ColumnSettingsPopover: 컬럼 설정 UI 컴포넌트 - UniversalListPage/IntegratedListTemplateV2에 컬럼 설정 통합 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1147 lines
46 KiB
TypeScript
1147 lines
46 KiB
TypeScript
"use client";
|
|
|
|
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
|
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react";
|
|
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
|
import { TableSkeleton, MobileCardGridSkeleton, StatCardGridSkeleton, ListPageSkeleton } from "@/components/ui/skeleton";
|
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import { Input } from "@/components/ui/input";
|
|
import { PageLayout } from "@/components/organisms/PageLayout";
|
|
import { PageHeader } from "@/components/organisms/PageHeader";
|
|
import { StatCards } from "@/components/organisms/StatCards";
|
|
import { SearchFilter } from "@/components/organisms/SearchFilter";
|
|
import { ScreenVersionHistory } from "@/components/organisms/ScreenVersionHistory";
|
|
import { TabChip } from "@/components/atoms/TabChip";
|
|
import { MultiSelectCombobox } from "@/components/ui/multi-select-combobox";
|
|
import { MobileFilter, FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";
|
|
import { formatNumber } from '@/lib/utils/amount';
|
|
|
|
/**
|
|
* 기본 통합 목록_버젼2
|
|
*
|
|
* 품목관리 스타일의 완전한 목록 템플릿
|
|
* - PageHeader, StatCards, SearchFilter, ScreenVersionHistory
|
|
* - 탭 기반 필터 (데스크톱: TabsList, 모바일: 커스텀 버튼)
|
|
* - 체크박스 포함 DataTable (Desktop)
|
|
* - 체크박스 포함 모바일 카드 (Mobile)
|
|
* - 페이지네이션
|
|
*/
|
|
|
|
export interface TabOption {
|
|
value: string;
|
|
label: string;
|
|
count: number;
|
|
color?: string; // 모바일 탭 색상
|
|
}
|
|
|
|
export interface TableColumn {
|
|
key: string;
|
|
label: string;
|
|
className?: string;
|
|
hideOnMobile?: boolean;
|
|
hideOnTablet?: boolean;
|
|
/** 정렬 가능 여부 */
|
|
sortable?: boolean;
|
|
}
|
|
|
|
export interface PaginationConfig {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
totalItems: number;
|
|
itemsPerPage: number;
|
|
onPageChange: (page: number) => void;
|
|
}
|
|
|
|
export interface StatCard {
|
|
label: string;
|
|
value: string | number;
|
|
icon: LucideIcon;
|
|
iconColor: string;
|
|
onClick?: () => void;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export interface VersionHistoryItem {
|
|
version: string;
|
|
description: string;
|
|
modifiedBy: string;
|
|
modifiedAt: string;
|
|
}
|
|
|
|
export interface DevMetadata {
|
|
componentName: string;
|
|
pagePath: string;
|
|
description: string;
|
|
apis?: unknown[];
|
|
dataStructures?: unknown[];
|
|
dbSchema?: unknown[];
|
|
businessLogic?: unknown[];
|
|
}
|
|
|
|
export interface IntegratedListTemplateV2Props<T = any> {
|
|
// 페이지 헤더
|
|
title: string;
|
|
description?: string;
|
|
icon?: LucideIcon;
|
|
headerActions?: ReactNode;
|
|
|
|
// ===== 공통 헤더 옵션 (달력/등록버튼) =====
|
|
/**
|
|
* 날짜 범위 선택기 (왼쪽 배치)
|
|
* - enabled: 달력 표시 여부
|
|
* - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘)
|
|
* - startDate/endDate: 외부 상태 연동
|
|
* - onChange: 날짜 변경 콜백
|
|
*/
|
|
dateRangeSelector?: {
|
|
enabled: boolean;
|
|
showPresets?: boolean;
|
|
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
|
hideDateInputs?: boolean;
|
|
/** 표시할 프리셋 목록 */
|
|
presets?: import('@/components/molecules/DateRangeSelector').DatePreset[];
|
|
/** 프리셋 레이블 커스텀 오버라이드 */
|
|
presetLabels?: Partial<Record<import('@/components/molecules/DateRangeSelector').DatePreset, string>>;
|
|
/** 프리셋 버튼 위치 */
|
|
presetsPosition?: 'inline' | 'below';
|
|
/** 날짜 입력 변형: 'split' (DatePicker 2개), 'combined' (DateRangePicker 1개) */
|
|
variant?: 'split' | 'combined';
|
|
startDate?: string;
|
|
endDate?: string;
|
|
onStartDateChange?: (date: string) => void;
|
|
onEndDateChange?: (date: string) => void;
|
|
/** 추가 액션 (검색창 등) - 프리셋 버튼 옆에 배치 */
|
|
extraActions?: ReactNode;
|
|
};
|
|
/**
|
|
* 등록 버튼 (오른쪽 끝 배치)
|
|
* - label: 버튼 텍스트 (예: '등록', '공정 등록')
|
|
* - onClick: 클릭 핸들러
|
|
* - icon: 아이콘 (기본: Plus)
|
|
*/
|
|
createButton?: {
|
|
label: string;
|
|
onClick: () => void;
|
|
icon?: LucideIcon;
|
|
};
|
|
|
|
// 탭 콘텐츠 (헤더 액션 아래, 검색 위에 표시되는 커스텀 탭)
|
|
tabsContent?: ReactNode;
|
|
|
|
// 통계 카드
|
|
stats?: StatCard[];
|
|
|
|
// 경고 배너 (통계 카드와 검색 영역 사이)
|
|
alertBanner?: ReactNode;
|
|
|
|
// 버전 이력
|
|
versionHistory?: VersionHistoryItem[];
|
|
versionHistoryTitle?: string;
|
|
|
|
// 검색 및 필터
|
|
searchValue?: string;
|
|
onSearchChange?: (value: string) => void;
|
|
searchPlaceholder?: string;
|
|
extraFilters?: ReactNode; // Select, DatePicker 등 추가 필터
|
|
hideSearch?: boolean; // 검색창 숨김 여부
|
|
|
|
// 탭 (품목 유형, 상태 등) - optional
|
|
tabs?: TabOption[];
|
|
activeTab?: string;
|
|
onTabChange?: (value: string) => void;
|
|
/** 탭 렌더링 위치: 'card' (기본, 테이블 카드 내부) | 'above-stats' (통계 카드 위) */
|
|
tabsPosition?: 'card' | 'above-stats';
|
|
|
|
// 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등)
|
|
tableHeaderActions?: ReactNode;
|
|
|
|
// 모바일/카드 뷰용 필터 슬롯 (xl 미만에서 카드 목록 위에 표시)
|
|
mobileFilterSlot?: ReactNode;
|
|
|
|
// ===== 새로운 통합 필터 시스템 (선택적 사용) =====
|
|
// filterConfig를 전달하면 PC는 인라인, 모바일은 바텀시트로 자동 분기
|
|
// 기존 tableHeaderActions, mobileFilterSlot과 함께 사용 가능
|
|
filterConfig?: FilterFieldConfig[];
|
|
filterValues?: FilterValues;
|
|
onFilterChange?: (key: string, value: string | string[]) => void;
|
|
onFilterReset?: () => void;
|
|
filterTitle?: string; // 모바일 필터 바텀시트 제목 (기본: "검색 필터")
|
|
|
|
// 테이블 앞에 표시될 컨텐츠 (계정과목명 + 저장 버튼 등)
|
|
beforeTableContent?: ReactNode;
|
|
|
|
// 테이블 뒤에 표시될 컨텐츠 (캘린더 등)
|
|
afterTableContent?: ReactNode;
|
|
|
|
// 테이블 컬럼
|
|
tableColumns: TableColumn[];
|
|
tableTitle?: string; // "전체 목록 (100개)" 같은 타이틀
|
|
|
|
// ===== 정렬 설정 =====
|
|
/** 현재 정렬 컬럼 키 */
|
|
sortBy?: string;
|
|
/** 정렬 방향 */
|
|
sortOrder?: 'asc' | 'desc';
|
|
/** 정렬 변경 핸들러 */
|
|
onSort?: (key: string) => void;
|
|
|
|
// 커스텀 테이블 헤더 렌더링 (동적 컬럼용)
|
|
renderCustomTableHeader?: () => ReactNode;
|
|
|
|
// 테이블 하단 푸터 (합계 등)
|
|
tableFooter?: ReactNode;
|
|
|
|
// 데이터
|
|
data: T[]; // 데스크톱용 페이지네이션된 데이터
|
|
totalCount?: number; // 전체 데이터 개수 (역순 번호 계산용)
|
|
allData?: T[]; // 클라이언트 사이드 필터링용 전체 데이터 (소량 데이터 페이지용)
|
|
mobileDisplayCount?: number; // 클라이언트 사이드 인피니티에서 표시할 개수
|
|
onLoadMore?: () => void; // 더 불러오기 콜백 (레거시)
|
|
|
|
// ===== 서버 사이드 모바일 인피니티 스크롤 =====
|
|
// 모바일에서 스크롤/버튼으로 다음 페이지 로드, 데이터 누적 표시
|
|
enableMobileInfinityScroll?: boolean; // 서버 사이드 인피니티 활성화 (기본: true)
|
|
isMobileLoading?: boolean; // 모바일 추가 로딩 중 상태
|
|
|
|
// 체크박스 선택
|
|
selectedItems: Set<string>;
|
|
onToggleSelection: (id: string) => void;
|
|
onToggleSelectAll: () => void;
|
|
getItemId: (item: T) => string; // 아이템에서 ID 추출
|
|
onBulkDelete?: () => void; // 일괄 삭제 핸들러
|
|
/** 선택 액션 버튼 (테이블 왼쪽 "전체 N건 / N개 선택됨" 옆에 표시) */
|
|
selectionActions?: ReactNode;
|
|
|
|
// 테이블 표시 옵션
|
|
showCheckbox?: boolean; // 체크박스 표시 여부 (기본: true)
|
|
showRowNumber?: boolean; // 번호 컬럼 표시 여부 (기본: true, tableColumns에 번호 포함 시)
|
|
|
|
// 렌더링 함수
|
|
renderTableRow: (item: T, index: number, globalIndex: number) => ReactNode;
|
|
renderMobileCard: (item: T, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => ReactNode;
|
|
|
|
// 페이지네이션
|
|
pagination: PaginationConfig;
|
|
|
|
// 개발자 메타데이터
|
|
devMetadata?: DevMetadata;
|
|
|
|
// 로딩 상태
|
|
isLoading?: boolean;
|
|
|
|
// ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) =====
|
|
columnSettings?: {
|
|
columnWidths: Record<string, number>;
|
|
onColumnResize: (columnKey: string, width: number) => void;
|
|
settingsPopover: ReactNode;
|
|
};
|
|
}
|
|
|
|
export function IntegratedListTemplateV2<T = any>({
|
|
title,
|
|
description,
|
|
icon,
|
|
headerActions,
|
|
dateRangeSelector,
|
|
createButton,
|
|
tabsContent,
|
|
stats,
|
|
alertBanner,
|
|
versionHistory,
|
|
versionHistoryTitle = "수정 이력",
|
|
searchValue,
|
|
onSearchChange,
|
|
searchPlaceholder = "검색...",
|
|
extraFilters,
|
|
hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김)
|
|
tabs,
|
|
activeTab,
|
|
onTabChange,
|
|
tabsPosition = 'card',
|
|
tableHeaderActions,
|
|
mobileFilterSlot,
|
|
filterConfig,
|
|
filterValues,
|
|
onFilterChange,
|
|
onFilterReset,
|
|
filterTitle = "검색 필터",
|
|
beforeTableContent,
|
|
afterTableContent,
|
|
tableColumns,
|
|
tableTitle,
|
|
sortBy,
|
|
sortOrder,
|
|
onSort,
|
|
renderCustomTableHeader,
|
|
tableFooter,
|
|
data,
|
|
totalCount,
|
|
allData,
|
|
mobileDisplayCount,
|
|
onLoadMore,
|
|
enableMobileInfinityScroll = true, // 기본값: 활성화
|
|
isMobileLoading = false,
|
|
selectedItems,
|
|
onToggleSelection,
|
|
onToggleSelectAll,
|
|
getItemId,
|
|
onBulkDelete,
|
|
selectionActions,
|
|
showCheckbox = true, // 기본값 true
|
|
showRowNumber = true, // 기본값 true (번호 컬럼은 renderTableRow에서 처리)
|
|
renderTableRow,
|
|
renderMobileCard,
|
|
pagination,
|
|
devMetadata,
|
|
isLoading,
|
|
columnSettings,
|
|
}: IntegratedListTemplateV2Props<T>) {
|
|
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
|
|
// ===== 서버 사이드 모바일 인피니티 스크롤 =====
|
|
// 모바일에서 누적 데이터를 관리하여 스크롤 시 계속 추가
|
|
const [accumulatedMobileData, setAccumulatedMobileData] = useState<T[]>([]);
|
|
const [lastAccumulatedPage, setLastAccumulatedPage] = useState(0);
|
|
const mobileScrollSentinelRef = useRef<HTMLDivElement>(null);
|
|
const mobileCardAreaRef = 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 => {
|
|
const existingIds = new Set(prev.map(item => getItemId(item)));
|
|
const newItems = data.filter(item => !existingIds.has(getItemId(item)));
|
|
return [...prev, ...newItems];
|
|
});
|
|
setLastAccumulatedPage(pagination.currentPage);
|
|
} else if (pagination.currentPage !== lastAccumulatedPage) {
|
|
// 페이지 점프 (예: PC에서 페이지 변경 후 모바일로): 현재 데이터만 표시
|
|
setAccumulatedMobileData(data);
|
|
setLastAccumulatedPage(pagination.currentPage);
|
|
}
|
|
}, [data, pagination.currentPage, pagination.totalItems, allData, enableMobileInfinityScroll, lastAccumulatedPage, getItemId]);
|
|
|
|
// 탭 변경 감지: 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]);
|
|
|
|
// ===== 모바일 카드 영역 내부 스크롤 컨테인먼트 =====
|
|
// 카드 영역이 뷰포트 남은 높이만큼만 차지하고, 내부에서만 스크롤되도록 설정
|
|
// → 헤더/검색/탭은 항상 보이고, 카드만 스크롤
|
|
useEffect(() => {
|
|
const el = mobileCardAreaRef.current;
|
|
if (!el) return;
|
|
|
|
const applyScrollContainment = () => {
|
|
// xl(1280px) 이상은 데스크톱 → 테이블+페이지네이션 사용, 컨테인먼트 해제
|
|
if (window.innerWidth >= 1280) {
|
|
el.style.maxHeight = '';
|
|
el.style.overflowY = '';
|
|
el.style.overscrollBehavior = '';
|
|
return;
|
|
}
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const available = window.innerHeight - rect.top - 16; // 16px 하단 여유
|
|
|
|
if (available > 200) {
|
|
el.style.maxHeight = `${available}px`;
|
|
el.style.overflowY = 'auto';
|
|
el.style.overscrollBehavior = 'contain'; // 스크롤 누수 방지
|
|
}
|
|
};
|
|
|
|
// 페이지 스크롤을 최상단으로 리셋 후 정확한 위치 측정
|
|
window.scrollTo(0, 0);
|
|
el.scrollTop = 0;
|
|
|
|
const raf = requestAnimationFrame(applyScrollContainment);
|
|
window.addEventListener('resize', applyScrollContainment);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener('resize', applyScrollContainment);
|
|
};
|
|
}, [activeTab, isLoading]);
|
|
|
|
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 = () => {
|
|
if (!filterConfig || !filterValues || !onFilterChange) return null;
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{filterConfig.map((field) => {
|
|
if (field.type === 'single') {
|
|
// 단일선택: Select
|
|
return (
|
|
<Select
|
|
key={field.key}
|
|
value={(filterValues[field.key] as string) || 'all'}
|
|
onValueChange={(value) => onFilterChange(field.key, value)}
|
|
>
|
|
<SelectTrigger className="min-w-[120px] w-auto">
|
|
<SelectValue placeholder={field.allOptionLabel || field.label} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">
|
|
{field.allOptionLabel || '전체'}
|
|
</SelectItem>
|
|
{field.options.filter(opt => opt.value !== '').map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
} else {
|
|
// 다중선택: MultiSelectCombobox
|
|
return (
|
|
<MultiSelectCombobox
|
|
key={field.key}
|
|
options={field.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
}))}
|
|
value={(filterValues[field.key] as string[]) || []}
|
|
onChange={(value) => onFilterChange(field.key, value)}
|
|
placeholder={field.label}
|
|
searchPlaceholder={`${field.label} 검색...`}
|
|
className="w-[140px]"
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 모바일용 바텀시트 필터 (xl 미만에서 표시)
|
|
const renderAutoMobileFilter = () => {
|
|
if (!filterConfig || !filterValues || !onFilterChange || !onFilterReset) return null;
|
|
|
|
return (
|
|
<MobileFilter
|
|
fields={filterConfig}
|
|
values={filterValues}
|
|
onChange={onFilterChange}
|
|
onReset={onFilterReset}
|
|
buttonLabel="필터"
|
|
title={filterTitle}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// 일괄삭제 확인 핸들러
|
|
const handleBulkDeleteClick = () => {
|
|
setShowDeleteDialog(true);
|
|
};
|
|
|
|
// 일괄삭제 실행
|
|
const handleConfirmDelete = () => {
|
|
if (onBulkDelete) {
|
|
onBulkDelete();
|
|
}
|
|
setShowDeleteDialog(false);
|
|
};
|
|
|
|
// 헤더 액션 스켈레톤 (달력 + 프리셋 버튼 + 등록 버튼)
|
|
const renderHeaderActionSkeleton = () => (
|
|
<div className="flex items-center gap-2 flex-wrap w-full">
|
|
{dateRangeSelector?.enabled && (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-10 w-[140px] rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
<div className="h-4 w-4 bg-gray-300 rounded animate-pulse" />
|
|
<div className="h-10 w-[140px] rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
</div>
|
|
<div className="hidden md:flex items-center gap-1.5">
|
|
<div className="h-8 w-16 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
<div className="h-8 w-14 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
<div className="h-8 w-12 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
<div className="h-8 w-12 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
<div className="h-8 w-10 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
<div className="h-8 w-10 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
|
|
</div>
|
|
</>
|
|
)}
|
|
{createButton && (
|
|
<div className="h-10 w-28 rounded-md border border-gray-200 bg-gray-100 animate-pulse ml-auto" />
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<PageLayout>
|
|
{/* 페이지 헤더 - 항상 표시 */}
|
|
<PageHeader
|
|
title={title}
|
|
description={description}
|
|
icon={icon}
|
|
/>
|
|
|
|
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
|
{/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
|
|
{(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
|
|
<div className="flex flex-col xl:flex-row xl:flex-wrap xl:items-center xl:justify-between gap-2 w-full">
|
|
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */}
|
|
{dateRangeSelector?.enabled ? (
|
|
<DateRangeSelector
|
|
startDate={dateRangeSelector.startDate || ''}
|
|
endDate={dateRangeSelector.endDate || ''}
|
|
onStartDateChange={dateRangeSelector.onStartDateChange || (() => {})}
|
|
onEndDateChange={dateRangeSelector.onEndDateChange || (() => {})}
|
|
hidePresets={dateRangeSelector.showPresets === false}
|
|
hideDateInputs={dateRangeSelector.hideDateInputs}
|
|
presets={dateRangeSelector.presets}
|
|
presetLabels={dateRangeSelector.presetLabels}
|
|
presetsPosition={dateRangeSelector.presetsPosition}
|
|
variant={dateRangeSelector.variant}
|
|
extraActions={
|
|
<>
|
|
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
|
|
{hideSearch && onSearchChange && (
|
|
<div className="relative w-full xl:w-[300px]">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder={searchPlaceholder}
|
|
value={searchValue || ''}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="pl-9 w-full bg-gray-50 border-gray-200"
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 기존 extraActions (추가 버튼 등) */}
|
|
{dateRangeSelector.extraActions}
|
|
</>
|
|
}
|
|
/>
|
|
) : (
|
|
/* dateRangeSelector 없어도 hideSearch=true면 검색창 표시 */
|
|
hideSearch && onSearchChange && (
|
|
<div className="relative w-full xl:w-[300px]">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder={searchPlaceholder}
|
|
value={searchValue || ''}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="pl-9 w-full bg-gray-50 border-gray-200"
|
|
/>
|
|
</div>
|
|
)
|
|
)}
|
|
{/* 버튼 영역 (오른쪽 배치, 공간 부족시 자연스럽게 줄바꿈) */}
|
|
{(headerActions || createButton) && (
|
|
<div className="mobile-actions-grid xl:flex xl:items-center xl:gap-2 xl:shrink-0">
|
|
{/* 헤더 액션 (엑셀 다운로드 등 추가 버튼들) */}
|
|
{headerActions}
|
|
{/* 등록 버튼 */}
|
|
{createButton && (
|
|
<Button onClick={createButton.onClick}>
|
|
{createButton.icon ? (
|
|
<createButton.icon className="h-4 w-4 mr-2" />
|
|
) : (
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
)}
|
|
{createButton.label}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */}
|
|
{tabsContent && (
|
|
<div className="flex items-center">
|
|
{tabsContent}
|
|
</div>
|
|
)}
|
|
|
|
{/* 탭 - 카드 밖 (tabsPosition === 'above-stats') */}
|
|
{tabsPosition === 'above-stats' && tabs && tabs.length > 0 && (
|
|
<div className="xl:overflow-x-auto">
|
|
<div className="mobile-tab-grid flex gap-2 xl:min-w-max">
|
|
{tabs.map((tab) => (
|
|
<TabChip
|
|
key={tab.value}
|
|
label={tab.label}
|
|
count={tab.count}
|
|
active={activeTab === tab.value}
|
|
onClick={() => onTabChange?.(tab.value)}
|
|
color={tab.color as any}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 통계 카드 - 태블릿/데스크톱 */}
|
|
{stats && stats.length > 0 ? (
|
|
<div className="hidden md:block">
|
|
<StatCards stats={stats} />
|
|
</div>
|
|
) : null}
|
|
|
|
{/* 경고 배너 (통계 카드와 검색 영역 사이) */}
|
|
{alertBanner}
|
|
|
|
{/* 버전 이력 */}
|
|
{versionHistory && versionHistory.length > 0 && (
|
|
<ScreenVersionHistory
|
|
versionHistory={versionHistory as any}
|
|
title={versionHistoryTitle}
|
|
/>
|
|
)}
|
|
|
|
{/* 검색 및 필터 */}
|
|
{!hideSearch && (
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<SearchFilter
|
|
searchValue={searchValue || ''}
|
|
onSearchChange={onSearchChange || (() => {})}
|
|
searchPlaceholder={searchPlaceholder}
|
|
filterButton={false}
|
|
extraActions={extraFilters}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 테이블 앞 컨텐츠 (계정과목명 + 저장 버튼, 달력 등) */}
|
|
{beforeTableContent && (
|
|
<div className="w-full py-2">
|
|
{beforeTableContent}
|
|
</div>
|
|
)}
|
|
|
|
{/* 목록 카드 */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<Tabs value={activeTab || 'default'} onValueChange={onTabChange} className="w-full">
|
|
{/* 데스크톱 (1280px+) - TabChip 탭 */}
|
|
<div className="hidden xl:block mb-4">
|
|
<div className="flex flex-wrap gap-2 justify-between items-center">
|
|
{/* 왼쪽: 전체 건수 + 선택 정보 + 선택삭제 */}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-muted-foreground">
|
|
전체 {pagination.totalItems}건
|
|
</span>
|
|
{selectedItems.size > 0 && (
|
|
<>
|
|
<span className="text-muted-foreground">/</span>
|
|
<span className="text-primary font-medium">
|
|
{selectedItems.size}개 항목 선택됨
|
|
</span>
|
|
</>
|
|
)}
|
|
{selectedItems.size >= 1 && onBulkDelete && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleBulkDeleteClick}
|
|
className="flex items-center gap-2 bg-gray-900 text-white hover:bg-gray-800 hover:text-white"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
선택삭제
|
|
</Button>
|
|
)}
|
|
{/* 선택 액션 버튼 (상신, 승인, 삭제 등) */}
|
|
{selectedItems.size > 0 && selectionActions}
|
|
</div>
|
|
{/* 오른쪽: 탭 + 헤더 액션 + 필터 */}
|
|
<div className="flex items-center gap-2">
|
|
{tabsPosition !== 'above-stats' && tabs && tabs.map((tab) => (
|
|
<TabChip
|
|
key={tab.value}
|
|
label={tab.label}
|
|
count={tab.count}
|
|
active={activeTab === tab.value}
|
|
onClick={() => onTabChange?.(tab.value)}
|
|
color={tab.color as any}
|
|
/>
|
|
))}
|
|
{tableHeaderActions}
|
|
{renderAutoFilters()}
|
|
{columnSettings?.settingsPopover}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모바일/태블릿 (~1279px) - TabChip 탭 */}
|
|
{tabsPosition !== 'above-stats' && tabs && tabs.length > 0 && (
|
|
<div className="xl:hidden mb-4">
|
|
<div className="mobile-tab-grid flex gap-2">
|
|
{tabs.map((tab) => (
|
|
<TabChip
|
|
key={tab.value}
|
|
label={tab.label}
|
|
count={tab.count}
|
|
active={activeTab === tab.value}
|
|
onClick={() => onTabChange?.(tab.value)}
|
|
color={tab.color as any}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 탭 컨텐츠 */}
|
|
{(tabs || [{ value: 'default', label: '', count: 0 }]).map((tab) => (
|
|
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
|
{/* 모바일/태블릿/소형 노트북 (~1279px) - 선택 액션 + 선택 삭제 하단 고정 바 */}
|
|
{selectedItems.size > 0 && (selectionActions || onBulkDelete) && (
|
|
<div className="xl:hidden fixed bottom-0 left-0 right-0 p-3 bg-white border-t shadow-lg z-50">
|
|
<div className="flex items-center gap-2 mb-2 text-sm">
|
|
<span className="text-primary font-medium">
|
|
{selectedItems.size}개 선택됨
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2 overflow-x-auto">
|
|
{selectionActions}
|
|
{selectedItems.size >= 1 && onBulkDelete && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleBulkDeleteClick}
|
|
className="flex items-center gap-2 bg-gray-900 text-white hover:bg-gray-800 hover:text-white shrink-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
선택삭제
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모바일/카드 뷰용 필터 - filterConfig 자동 생성 또는 기존 mobileFilterSlot */}
|
|
{(filterConfig || mobileFilterSlot) && (
|
|
<div className="xl:hidden mb-4">
|
|
{/* filterConfig가 있으면 자동 생성된 MobileFilter 사용 */}
|
|
{renderAutoMobileFilter()}
|
|
{/* 기존 방식: mobileFilterSlot 직접 전달 */}
|
|
{mobileFilterSlot}
|
|
</div>
|
|
)}
|
|
|
|
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
|
|
<div ref={mobileCardAreaRef} className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
|
|
{isLoading ? (
|
|
<div className="col-span-full">
|
|
<MobileCardGridSkeleton count={6} showCheckbox={showCheckbox} />
|
|
</div>
|
|
) : mobileData.length === 0 ? (
|
|
<div className="text-center py-6 text-muted-foreground border rounded-lg text-[14px]">
|
|
검색 결과가 없습니다.
|
|
</div>
|
|
) : (
|
|
// 인피니티 스크롤: mobileData는 allData?.slice(0, displayCount) || data
|
|
mobileData.map((item, index) => {
|
|
const itemId = getItemId(item);
|
|
const isSelected = selectedItems.has(itemId);
|
|
// 순차 번호: 1번부터 시작
|
|
const globalIndex = index + 1;
|
|
|
|
return (
|
|
<div key={itemId}>
|
|
{renderMobileCard(
|
|
item,
|
|
index,
|
|
globalIndex,
|
|
isSelected,
|
|
() => onToggleSelection(itemId)
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
{/* 모바일 인피니티 스크롤 - 더 보기 버튼 & 로딩 표시 */}
|
|
{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">
|
|
{formatNumber(loadedCount)} / {formatNumber(totalDataCount)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
모든 항목을 불러왔습니다 ({formatNumber(totalDataCount)}개)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 데스크톱 (1280px+) 테이블 뷰 */}
|
|
<div className="hidden xl:block rounded-md border overflow-x-auto [&::-webkit-scrollbar]:h-3 [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:hover:bg-gray-400" style={{ scrollbarWidth: 'thin', scrollbarColor: '#d1d5db #f3f4f6' }}>
|
|
{isLoading ? (
|
|
<TableSkeleton
|
|
rows={pagination.itemsPerPage || 10}
|
|
columns={tableColumns.length}
|
|
showCheckbox={showCheckbox}
|
|
showActions={tableColumns.some(col => col.key === 'actions')}
|
|
/>
|
|
) : (
|
|
<Table className="table-fixed">
|
|
{columnSettings && (
|
|
<colgroup>
|
|
{showCheckbox && <col style={{ width: 50 }} />}
|
|
{tableColumns.map((col) => (
|
|
<col
|
|
key={col.key}
|
|
style={columnSettings.columnWidths[col.key] ? { width: columnSettings.columnWidths[col.key] } : undefined}
|
|
/>
|
|
))}
|
|
</colgroup>
|
|
)}
|
|
<TableHeader>
|
|
<TableRow>
|
|
{renderCustomTableHeader ? (
|
|
// 커스텀 테이블 헤더 사용 (동적 컬럼용)
|
|
renderCustomTableHeader()
|
|
) : (
|
|
// 기본 테이블 헤더
|
|
<>
|
|
{showCheckbox && (
|
|
<TableHead className="w-[50px] min-w-[50px] max-w-[50px] text-center">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
onCheckedChange={onToggleSelectAll}
|
|
/>
|
|
</TableHead>
|
|
)}
|
|
{tableColumns.map((column) => {
|
|
// "actions" 컬럼은 항상 렌더링하되, 선택된 항목이 없을 때는 빈 헤더로 표시
|
|
const isSortable = column.sortable && onSort;
|
|
const isCurrentSort = sortBy === column.key;
|
|
|
|
return (
|
|
<TableHead
|
|
key={column.key}
|
|
className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''} ${columnSettings ? 'relative' : ''}`}
|
|
onClick={isSortable ? () => onSort(column.key) : undefined}
|
|
>
|
|
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
|
<div className={`flex items-center gap-1 ${isSortable ? 'group' : ''} ${(column.className || '').includes('text-right') ? 'justify-end' : (column.className || '').includes('text-center') ? 'justify-center' : ''}`}>
|
|
<span>{column.label}</span>
|
|
{isSortable && (
|
|
<span className="text-muted-foreground">
|
|
{isCurrentSort ? (
|
|
sortOrder === 'asc' ? (
|
|
<ArrowUp className="h-4 w-4" />
|
|
) : (
|
|
<ArrowDown className="h-4 w-4" />
|
|
)
|
|
) : (
|
|
<ArrowUpDown className="h-4 w-4 opacity-0 group-hover:opacity-50" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{columnSettings && (
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-400 active:bg-blue-500 z-10"
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const th = (e.target as HTMLElement).parentElement;
|
|
if (!th) return;
|
|
const startX = e.clientX;
|
|
const startWidth = th.offsetWidth;
|
|
const onMouseMove = (ev: MouseEvent) => {
|
|
const newWidth = Math.max(40, startWidth + ev.clientX - startX);
|
|
columnSettings.onColumnResize(column.key, newWidth);
|
|
};
|
|
const onMouseUp = () => {
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
};
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
}}
|
|
/>
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className="[&_tr]:h-14 [&_tr]:min-h-[56px] [&_tr]:max-h-[56px]">
|
|
{data.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={tableColumns.length + (showCheckbox ? 1 : 0)}
|
|
className="h-24 text-center"
|
|
>
|
|
검색 결과가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
// 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순)
|
|
data.map((item, index) => {
|
|
const itemId = getItemId(item);
|
|
// 순차 번호: startIndex 기준으로 1부터 시작
|
|
const globalIndex = startIndex + index + 1;
|
|
return (
|
|
<Fragment key={itemId}>
|
|
{renderTableRow(item, index, globalIndex)}
|
|
</Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
{tableFooter && (
|
|
<TableFooter>
|
|
{tableFooter}
|
|
</TableFooter>
|
|
)}
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 페이지네이션 - 데스크톱에서만 표시 (1페이지여도 항상 표시) */}
|
|
<div className="hidden xl:flex items-center justify-between">
|
|
<div className="text-sm text-muted-foreground">
|
|
전체 {pagination.totalItems}개 중 {pagination.totalItems > 0 ? startIndex + 1 : 0}-{Math.min(startIndex + pagination.itemsPerPage, pagination.totalItems)}개 표시
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => pagination.onPageChange(pagination.currentPage - 1)}
|
|
disabled={pagination.currentPage === 1}
|
|
>
|
|
이전
|
|
</Button>
|
|
<div className="flex items-center gap-1">
|
|
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => {
|
|
// 현재 페이지 근처만 표시
|
|
if (
|
|
page === 1 ||
|
|
page === pagination.totalPages ||
|
|
(page >= pagination.currentPage - 2 && page <= pagination.currentPage + 2)
|
|
) {
|
|
return (
|
|
<Button
|
|
key={page}
|
|
variant={page === pagination.currentPage ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => pagination.onPageChange(page)}
|
|
className="min-w-[36px]"
|
|
>
|
|
{page}
|
|
</Button>
|
|
);
|
|
} else if (
|
|
page === pagination.currentPage - 3 ||
|
|
page === pagination.currentPage + 3
|
|
) {
|
|
return <span key={page} className="px-2">...</span>;
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => pagination.onPageChange(pagination.currentPage + 1)}
|
|
disabled={pagination.currentPage === pagination.totalPages}
|
|
>
|
|
다음
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 뒤 컨텐츠 (캘린더 등) */}
|
|
{afterTableContent && (
|
|
<div className="mt-4">{afterTableContent}</div>
|
|
)}
|
|
|
|
{/* 일괄 삭제 확인 다이얼로그 */}
|
|
<DeleteConfirmDialog
|
|
open={showDeleteDialog}
|
|
onOpenChange={setShowDeleteDialog}
|
|
onConfirm={handleConfirmDelete}
|
|
description={
|
|
<>
|
|
선택한 <strong>{selectedItems.size}개</strong>의 항목을 삭제하시겠습니까?
|
|
<br />
|
|
<span className="text-muted-foreground text-sm">
|
|
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
|
</span>
|
|
</>
|
|
}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// 필터 관련 타입 재export (다른 페이지에서 사용 가능)
|
|
export type { FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter"; |