"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"; /** * 기본 통합 목록_버젼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?: any[]; dataStructures?: any[]; dbSchema?: any[]; businessLogic?: any[]; } export interface IntegratedListTemplateV2Props { // 페이지 헤더 title: string; description?: string; icon?: LucideIcon; headerActions?: ReactNode; // ===== 공통 헤더 옵션 (달력/등록버튼) ===== /** * 날짜 범위 선택기 (왼쪽 배치) * - enabled: 달력 표시 여부 * - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘) * - startDate/endDate: 외부 상태 연동 * - onChange: 날짜 변경 콜백 */ dateRangeSelector?: { enabled: boolean; showPresets?: boolean; /** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */ hideDateInputs?: boolean; 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; // 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등) 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; // 테이블 컬럼 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; onToggleSelection: (id: string) => void; onToggleSelectAll: () => void; getItemId: (item: T) => string; // 아이템에서 ID 추출 onBulkDelete?: () => void; // 일괄 삭제 핸들러 // 테이블 표시 옵션 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; } export function IntegratedListTemplateV2({ title, description, icon, headerActions, dateRangeSelector, createButton, tabsContent, stats, alertBanner, versionHistory, versionHistoryTitle = "수정 이력", searchValue, onSearchChange, searchPlaceholder = "검색...", extraFilters, hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김) tabs, activeTab, onTabChange, tableHeaderActions, mobileFilterSlot, filterConfig, filterValues, onFilterChange, onFilterReset, filterTitle = "검색 필터", beforeTableContent, tableColumns, tableTitle, sortBy, sortOrder, onSort, renderCustomTableHeader, tableFooter, data, totalCount, allData, mobileDisplayCount, onLoadMore, enableMobileInfinityScroll = true, // 기본값: 활성화 isMobileLoading = false, selectedItems, onToggleSelection, onToggleSelectAll, getItemId, onBulkDelete, showCheckbox = true, // 기본값 true showRowNumber = true, // 기본값 true (번호 컬럼은 renderTableRow에서 처리) renderTableRow, renderMobileCard, pagination, devMetadata, isLoading, }: IntegratedListTemplateV2Props) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); // ===== 서버 사이드 모바일 인피니티 스크롤 ===== // 모바일에서 누적 데이터를 관리하여 스크롤 시 계속 추가 const [accumulatedMobileData, setAccumulatedMobileData] = useState([]); const [lastAccumulatedPage, setLastAccumulatedPage] = useState(0); const mobileScrollSentinelRef = useRef(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]); 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 (
{filterConfig.map((field) => { if (field.type === 'single') { // 단일선택: Select return ( ); } else { // 다중선택: MultiSelectCombobox return ( ({ 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]" /> ); } })}
); }; // 모바일용 바텀시트 필터 (xl 미만에서 표시) const renderAutoMobileFilter = () => { if (!filterConfig || !filterValues || !onFilterChange || !onFilterReset) return null; return ( ); }; // 일괄삭제 확인 핸들러 const handleBulkDeleteClick = () => { setShowDeleteDialog(true); }; // 일괄삭제 실행 const handleConfirmDelete = () => { if (onBulkDelete) { onBulkDelete(); } setShowDeleteDialog(false); }; // 헤더 액션 스켈레톤 (달력 + 프리셋 버튼 + 등록 버튼) const renderHeaderActionSkeleton = () => (
{dateRangeSelector?.enabled && ( <>
)} {createButton && (
)}
); return ( {/* 페이지 헤더 - 항상 표시 */} {/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */} {/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */} {(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && ( isLoading ? renderHeaderActionSkeleton() : (
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */} {dateRangeSelector?.enabled ? ( {/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */} {hideSearch && onSearchChange && (
onSearchChange(e.target.value)} className="pl-9 w-full bg-gray-50 border-gray-200" />
)} {/* 기존 extraActions (추가 버튼 등) */} {dateRangeSelector.extraActions} } /> ) : ( /* dateRangeSelector 없어도 hideSearch=true면 검색창 표시 */ hideSearch && onSearchChange && (
onSearchChange(e.target.value)} className="pl-9 w-full bg-gray-50 border-gray-200" />
) )} {/* 버튼 영역 (오른쪽 끝으로 통합) */} {(headerActions || createButton) && (
{/* 헤더 액션 (엑셀 다운로드 등 추가 버튼들) */} {headerActions} {/* 등록 버튼 */} {createButton && ( )}
)}
) )} {/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */} {tabsContent && (
{tabsContent}
)} {/* 통계 카드 - 태블릿/데스크톱 */} {isLoading && stats !== undefined ? (
) : stats && stats.length > 0 ? (
) : null} {/* 경고 배너 (통계 카드와 검색 영역 사이) */} {alertBanner} {/* 버전 이력 */} {versionHistory && versionHistory.length > 0 && ( )} {/* 검색 및 필터 */} {!hideSearch && ( {isLoading ? (
) : ( {})} searchPlaceholder={searchPlaceholder} filterButton={false} extraActions={extraFilters} /> )} )} {/* 테이블 앞 컨텐츠 (계정과목명 + 저장 버튼, 달력 등) */} {beforeTableContent && (
{beforeTableContent}
)} {/* 목록 카드 */} {/* 데스크톱 (1280px+) - TabChip 탭 */}
{tabs && tabs.map((tab) => ( onTabChange?.(tab.value)} color={tab.color as any} /> ))}
{/* 선택된 항목 수 표시 */} {selectedItems.size > 0 && ( {selectedItems.size}개 항목 선택됨 )} {/* 테이블 헤더 액션 (총 N건 등) - 필터 앞에 배치 */} {tableHeaderActions} {/* filterConfig 기반 자동 필터 (PC) */} {renderAutoFilters()} {selectedItems.size >= 1 && onBulkDelete && ( )}
{/* 모바일/태블릿 (~1279px) - TabChip 탭 */} {tabs && tabs.length > 0 && (
{tabs.map((tab) => ( onTabChange?.(tab.value)} color={tab.color as any} /> ))}
)} {/* 탭 컨텐츠 */} {(tabs || [{ value: 'default', label: '', count: 0 }]).map((tab) => ( {/* 모바일/태블릿/소형 노트북 (~1279px) - 선택 삭제 버튼 */} {selectedItems.size >= 2 && onBulkDelete && (
)} {/* 모바일/카드 뷰용 필터 - filterConfig 자동 생성 또는 기존 mobileFilterSlot */} {(filterConfig || mobileFilterSlot) && (
{/* filterConfig가 있으면 자동 생성된 MobileFilter 사용 */} {renderAutoMobileFilter()} {/* 기존 방식: mobileFilterSlot 직접 전달 */} {mobileFilterSlot}
)} {/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
{isLoading ? (
) : mobileData.length === 0 ? (
검색 결과가 없습니다.
) : ( // 인피니티 스크롤: 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 (
{renderMobileCard( item, index, globalIndex, isSelected, () => onToggleSelection(itemId) )}
); }) )} {/* 모바일 인피니티 스크롤 - 더 보기 버튼 & 로딩 표시 */} {mobileData.length > 0 && (
{hasMoreData ? ( <> {/* 스크롤 감지용 Sentinel */} )}
{/* 데스크톱 (1280px+) 테이블 뷰 */}
{isLoading ? ( col.key === 'actions')} /> ) : ( {renderCustomTableHeader ? ( // 커스텀 테이블 헤더 사용 (동적 컬럼용) renderCustomTableHeader() ) : ( // 기본 테이블 헤더 <> {showCheckbox && ( )} {tableColumns.map((column) => { // "actions" 컬럼은 항상 렌더링하되, 선택된 항목이 없을 때는 빈 헤더로 표시 const isSortable = column.sortable && onSort; const isCurrentSort = sortBy === column.key; return ( onSort(column.key) : undefined} > {column.key === "actions" && selectedItems.size === 0 ? "" : (
{column.label} {isSortable && ( {isCurrentSort ? ( sortOrder === 'asc' ? ( ) : ( ) ) : ( )} )}
)}
); })} )}
{data.length === 0 ? ( 검색 결과가 없습니다. ) : ( // 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순) data.map((item, index) => { const itemId = getItemId(item); // 순차 번호: startIndex 기준으로 1부터 시작 const globalIndex = startIndex + index + 1; return ( {renderTableRow(item, index, globalIndex)} ); }) )} {tableFooter && ( {tableFooter} )}
)}
))} {/* 페이지네이션 - 데스크톱에서만 표시 (1페이지여도 항상 표시) */}
전체 {pagination.totalItems}개 중 {pagination.totalItems > 0 ? startIndex + 1 : 0}-{Math.min(startIndex + pagination.itemsPerPage, pagination.totalItems)}개 표시
{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 ( ); } else if ( page === pagination.currentPage - 3 || page === pagination.currentPage + 3 ) { return ...; } return null; })}
{/* 일괄 삭제 확인 다이얼로그 */} 선택한 {selectedItems.size}개의 항목을 삭제하시겠습니까?
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다. } /> ); } // 필터 관련 타입 재export (다른 페이지에서 사용 가능) export type { FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";