- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
740 lines
26 KiB
TypeScript
740 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { ReactNode, Fragment, useState, RefObject } from "react";
|
||
import { LucideIcon, Trash2, Plus } from "lucide-react";
|
||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||
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 {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
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";
|
||
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;
|
||
}
|
||
|
||
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<T = any> {
|
||
// 페이지 헤더
|
||
title: string;
|
||
description?: string;
|
||
icon?: LucideIcon;
|
||
headerActions?: ReactNode;
|
||
|
||
// ===== 공통 헤더 옵션 (달력/등록버튼) =====
|
||
/**
|
||
* 날짜 범위 선택기 (왼쪽 배치)
|
||
* - enabled: 달력 표시 여부
|
||
* - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘)
|
||
* - startDate/endDate: 외부 상태 연동
|
||
* - onChange: 날짜 변경 콜백
|
||
*/
|
||
dateRangeSelector?: {
|
||
enabled: boolean;
|
||
showPresets?: boolean;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
onStartDateChange?: (date: string) => void;
|
||
onEndDateChange?: (date: string) => void;
|
||
};
|
||
/**
|
||
* 등록 버튼 (오른쪽 끝 배치)
|
||
* - 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개)" 같은 타이틀
|
||
|
||
// 커스텀 테이블 헤더 렌더링 (동적 컬럼용)
|
||
renderCustomTableHeader?: () => ReactNode;
|
||
|
||
// 테이블 하단 푸터 (합계 등)
|
||
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;
|
||
|
||
// 로딩 상태
|
||
isLoading?: boolean;
|
||
}
|
||
|
||
export function IntegratedListTemplateV2<T = any>({
|
||
title,
|
||
description,
|
||
icon,
|
||
headerActions,
|
||
dateRangeSelector,
|
||
createButton,
|
||
tabsContent,
|
||
stats,
|
||
alertBanner,
|
||
versionHistory,
|
||
versionHistoryTitle = "수정 이력",
|
||
searchValue,
|
||
onSearchChange,
|
||
searchPlaceholder = "검색...",
|
||
extraFilters,
|
||
hideSearch = false,
|
||
tabs,
|
||
activeTab,
|
||
onTabChange,
|
||
tableHeaderActions,
|
||
mobileFilterSlot,
|
||
filterConfig,
|
||
filterValues,
|
||
onFilterChange,
|
||
onFilterReset,
|
||
filterTitle = "검색 필터",
|
||
beforeTableContent,
|
||
tableColumns,
|
||
tableTitle,
|
||
renderCustomTableHeader,
|
||
tableFooter,
|
||
data,
|
||
totalCount,
|
||
allData,
|
||
mobileDisplayCount,
|
||
onLoadMore,
|
||
infinityScrollSentinelRef,
|
||
selectedItems,
|
||
onToggleSelection,
|
||
onToggleSelectAll,
|
||
getItemId,
|
||
onBulkDelete,
|
||
showCheckbox = true, // 기본값 true
|
||
showRowNumber = true, // 기본값 true (번호 컬럼은 renderTableRow에서 처리)
|
||
renderTableRow,
|
||
renderMobileCard,
|
||
pagination,
|
||
devMetadata,
|
||
isLoading,
|
||
}: IntegratedListTemplateV2Props<T>) {
|
||
|
||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||
|
||
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
|
||
const allSelected = selectedItems.size === data.length && data.length > 0;
|
||
|
||
// ===== 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="w-[120px]">
|
||
<SelectValue placeholder={field.allOptionLabel || field.label} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">
|
||
{field.allOptionLabel || '전체'}
|
||
</SelectItem>
|
||
{field.options.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);
|
||
};
|
||
|
||
return (
|
||
<PageLayout>
|
||
{/* 페이지 헤더 */}
|
||
<PageHeader
|
||
title={title}
|
||
description={description}
|
||
icon={icon}
|
||
/>
|
||
|
||
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
||
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
|
||
{(dateRangeSelector?.enabled || createButton || headerActions) && (
|
||
<div className="flex items-center gap-2 flex-wrap w-full">
|
||
{/* 날짜 범위 선택기 (왼쪽) */}
|
||
{dateRangeSelector?.enabled && (
|
||
<DateRangeSelector
|
||
startDate={dateRangeSelector.startDate || ''}
|
||
endDate={dateRangeSelector.endDate || ''}
|
||
onStartDateChange={dateRangeSelector.onStartDateChange}
|
||
onEndDateChange={dateRangeSelector.onEndDateChange}
|
||
hidePresets={dateRangeSelector.showPresets === false}
|
||
/>
|
||
)}
|
||
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
|
||
{headerActions}
|
||
{/* 등록 버튼 (오른쪽 끝) */}
|
||
{createButton && (
|
||
<Button className="ml-auto" 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>
|
||
)}
|
||
|
||
{/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */}
|
||
{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="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 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>
|
||
)}
|
||
{/* 테이블 헤더 액션 (총 N건 등) - 필터 앞에 배치 */}
|
||
{tableHeaderActions}
|
||
{/* filterConfig 기반 자동 필터 (PC) */}
|
||
{renderAutoFilters()}
|
||
{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>
|
||
|
||
{/* 모바일/태블릿 (~1279px) - TabChip 탭 */}
|
||
{tabs && tabs.length > 0 && (
|
||
<div className="xl:hidden mb-4 overflow-x-auto">
|
||
<div className="flex gap-2 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>
|
||
)}
|
||
|
||
{/* 탭 컨텐츠 */}
|
||
{(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>
|
||
)}
|
||
|
||
{/* 모바일/카드 뷰용 필터 - filterConfig 자동 생성 또는 기존 mobileFilterSlot */}
|
||
{(filterConfig || mobileFilterSlot) && (
|
||
<div className="xl:hidden mb-4">
|
||
{/* filterConfig가 있으면 자동 생성된 MobileFilter 사용 */}
|
||
{renderAutoMobileFilter()}
|
||
{/* 기존 방식: mobileFilterSlot 직접 전달 */}
|
||
{mobileFilterSlot}
|
||
</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>
|
||
{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" 컬럼은 항상 렌더링하되, 선택된 항목이 없을 때는 빈 헤더로 표시
|
||
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>
|
||
|
||
{/* 페이지네이션 - 데스크톱에서만 표시 */}
|
||
<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>
|
||
{pagination.totalPages > 1 && (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// 필터 관련 타입 재export (다른 페이지에서 사용 가능)
|
||
export type { FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter"; |