Files
sam-react-prod/src/components/templates/IntegratedListTemplateV2.tsx

539 lines
19 KiB
TypeScript
Raw Normal View History

"use client";
import { ReactNode, Fragment, useState, RefObject } from "react";
import { LucideIcon, Trash2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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";
/**
* _버젼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;
}
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;
}
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<T = any> {
// 페이지 헤더
title: string;
description?: string;
icon?: LucideIcon;
headerActions?: ReactNode;
// 탭 콘텐츠 (헤더 액션 아래, 검색 위에 표시되는 커스텀 탭)
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;
// 테이블 앞에 표시될 컨텐츠 (계정과목명 + 저장 버튼 등)
beforeTableContent?: ReactNode;
// 테이블 컬럼
tableColumns: TableColumn[];
tableTitle?: string; // "전체 목록 (100개)" 같은 타이틀
// 테이블 하단 푸터 (합계 등)
tableFooter?: ReactNode;
// 데이터
data: T[]; // 데스크톱용 페이지네이션된 데이터
totalCount?: number; // 전체 데이터 개수 (역순 번호 계산용)
allData?: T[]; // 모바일 인피니티 스크롤용 전체 필터된 데이터
mobileDisplayCount?: number; // 모바일에서 표시할 개수
onLoadMore?: () => void; // 더 불러오기 콜백
infinityScrollSentinelRef?: RefObject<HTMLDivElement | null>; // 인피니티 스크롤용 sentinel ref
// 체크박스 선택
selectedItems: Set<string>;
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;
}
export function IntegratedListTemplateV2<T = any>({
title,
description,
icon,
headerActions,
tabsContent,
stats,
alertBanner,
versionHistory,
versionHistoryTitle = "수정 이력",
searchValue,
onSearchChange,
searchPlaceholder = "검색...",
extraFilters,
hideSearch = false,
tabs,
activeTab,
onTabChange,
tableHeaderActions,
beforeTableContent,
tableColumns,
tableTitle,
tableFooter,
data,
totalCount,
allData,
mobileDisplayCount,
onLoadMore,
infinityScrollSentinelRef,
selectedItems,
onToggleSelection,
onToggleSelectAll,
getItemId,
onBulkDelete,
showCheckbox = true, // 기본값 true
showRowNumber = true, // 기본값 true (번호 컬럼은 renderTableRow에서 처리)
renderTableRow,
renderMobileCard,
pagination,
devMetadata,
}: IntegratedListTemplateV2Props<T>) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
const allSelected = selectedItems.size === data.length && data.length > 0;
// 일괄삭제 확인 핸들러
const handleBulkDeleteClick = () => {
setShowDeleteDialog(true);
};
// 일괄삭제 실행
const handleConfirmDelete = () => {
if (onBulkDelete) {
onBulkDelete();
}
setShowDeleteDialog(false);
};
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={title}
description={description}
icon={icon}
/>
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
{headerActions && (
<div className="flex items-center gap-2 flex-wrap w-full">
{headerActions}
</div>
)}
{/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */}
{tabsContent && (
<div className="flex items-center">
{tabsContent}
</div>
)}
{/* 통계 카드 - 태블릿/데스크톱 */}
{stats && stats.length > 0 && (
<div className="hidden md:block">
<StatCards stats={stats} />
</div>
)}
{/* 경고 배너 (통계 카드와 검색 영역 사이) */}
{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="flex items-center gap-2 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 flex-wrap gap-2">
{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}
/>
))}
</div>
<div className="flex items-center gap-2">
{/* 선택된 항목 수 표시 */}
{selectedItems.size > 0 && (
<span className="text-sm text-muted-foreground">
{selectedItems.size}
</span>
)}
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) */}
{tableHeaderActions}
{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" />
({selectedItems.size})
</Button>
)}
</div>
</div>
</div>
{/* 탭 컨텐츠 */}
{(tabs || [{ value: 'default', label: '', count: 0 }]).map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="mt-0">
{/* 모바일/태블릿/소형 노트북 (~1279px) - 선택 삭제 버튼 */}
{selectedItems.size >= 2 && onBulkDelete && (
<div className="xl:hidden fixed bottom-0 left-0 right-0 p-4 bg-white border-t shadow-lg z-50">
<Button
variant="destructive"
size="lg"
onClick={handleBulkDeleteClick}
className="w-full flex items-center justify-center gap-2"
>
<Trash2 className="h-4 w-4" />
({selectedItems.size})
</Button>
</div>
)}
{/* 모바일/태블릿/소형 노트북 (~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 ? (
<div className="text-center py-6 text-muted-foreground border rounded-lg text-[14px]">
.
</div>
) : (
// 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순)
(allData || data).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>
);
})
)}
{/* 인피니티 스크롤 Sentinel */}
{infinityScrollSentinelRef && (
<div
ref={infinityScrollSentinelRef}
className="h-10 w-full col-span-full"
aria-hidden="true"
/>
)}
</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' }}>
<Table>
<TableHeader>
<TableRow>
{showCheckbox && (
<TableHead className="w-[50px] min-w-[50px] max-w-[50px] text-center">
<Checkbox
checked={allSelected}
onCheckedChange={onToggleSelectAll}
/>
</TableHead>
)}
{tableColumns.map((column) => {
// "actions" 컬럼은 항상 렌더링하되, 선택된 항목이 없을 때는 빈 헤더로 표시
return (
<TableHead
key={column.key}
className={column.className}
>
{column.key === "actions" && selectedItems.size === 0 ? "" : column.label}
</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>
{/* 페이지네이션 - 데스크톱에서만 표시 */}
{pagination.totalPages > 1 && (
<div className="hidden xl:flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{pagination.totalItems} {startIndex + 1}-{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>
)}
{/* 일괄 삭제 확인 다이얼로그 - 단일 삭제와 동일한 디자인 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<span className="text-yellow-600"></span>
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p className="text-foreground">
<strong>{selectedItems.size}</strong> ?
</p>
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 mt-0.5"></span>
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground"></span>
<br />
. .
</div>
</div>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
>
<Trash2 className="h-4 w-4" />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}