Files
sam-react-prod/src/components/templates/IntegratedListTemplateV2.tsx
byeongcheolryu ded0bc2439 fix: TypeScript 타입 오류 수정 및 설정 페이지 추가
- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext)
- HeadersInit → Record<string, string> 타입 변경
- Zustand useShallow 마이그레이션 (zustand/react/shallow)
- DataTable, ListPageTemplate 제네릭 타입 제약 추가
- 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한)
- HR 관리 페이지 추가 (급여, 휴가)
- 단가관리 페이지 리팩토링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:07:47 +09:00

450 lines
16 KiB
TypeScript

"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, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/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;
// 통계 카드
stats?: StatCard[];
// 버전 이력
versionHistory?: VersionHistoryItem[];
versionHistoryTitle?: string;
// 검색 및 필터
searchValue: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
extraFilters?: ReactNode; // Select, DatePicker 등 추가 필터
// 탭 (품목 유형, 상태 등)
tabs: TabOption[];
activeTab: string;
onTabChange: (value: string) => void;
// 테이블 컬럼
tableColumns: TableColumn[];
tableTitle?: string; // "전체 목록 (100개)" 같은 타이틀
// 데이터
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; // 일괄 삭제 핸들러
// 렌더링 함수
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,
stats,
versionHistory,
versionHistoryTitle = "수정 이력",
searchValue,
onSearchChange,
searchPlaceholder = "검색...",
extraFilters,
tabs,
activeTab,
onTabChange,
tableColumns,
tableTitle,
data,
totalCount,
allData,
mobileDisplayCount,
onLoadMore,
infinityScrollSentinelRef,
selectedItems,
onToggleSelection,
onToggleSelectAll,
getItemId,
onBulkDelete,
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}
actions={headerActions}
/>
{/* 통계 카드 - 태블릿/데스크톱 */}
{stats && stats.length > 0 && (
<div className="hidden md:block">
<StatCards stats={stats} />
</div>
)}
{/* 버전 이력 */}
{versionHistory && versionHistory.length > 0 && (
<ScreenVersionHistory
versionHistory={versionHistory as any}
title={versionHistoryTitle}
/>
)}
{/* 검색 및 필터 */}
<Card>
<CardContent className="p-6">
<SearchFilter
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder={searchPlaceholder}
filterButton={false}
extraActions={extraFilters}
/>
</CardContent>
</Card>
{/* 목록 카드 */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} 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.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>
{selectedItems.size >= 2 && onBulkDelete && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDeleteClick}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
({selectedItems.size})
</Button>
)}
</div>
</div>
{/* 탭 컨텐츠 */}
{tabs.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>
<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 + 1}
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>
</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>
)}
{/* 일괄 삭제 확인 다이얼로그 */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
? .
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteDialog(false)}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleConfirmDelete}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
);
}