- 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>
450 lines
16 KiB
TypeScript
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>
|
|
);
|
|
} |