Files
sam-react-prod/src/components/templates/IntegratedListTemplateV2.tsx
유병철 ceeeeb1ef4 feat(WEB): 테이블 컬럼 표시/숨김 설정 기능 추가
- useColumnSettings 훅: 컬럼 가시성 토글 로직
- useTableColumnStore: Zustand 기반 컬럼 설정 영속화 (localStorage)
- ColumnSettingsPopover: 컬럼 설정 UI 컴포넌트
- UniversalListPage/IntegratedListTemplateV2에 컬럼 설정 통합

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:09:17 +09:00

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";