Files
sam-react-prod/src/components/templates/IntegratedListTemplateV2.tsx
byeongcheolryu ad493bcea6 feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리
- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선
- 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션
- 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등)
- 미들웨어 토큰 갱신 로직 개선
- AuthenticatedLayout 구조 개선
- claudedocs 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:19:09 +09:00

740 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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