'use client'; import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfYear, endOfYear } from 'date-fns'; import { Package, Plus, Pencil, Trash2, PackageCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { TableCell, TableRow } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { UniversalListPage, type UniversalListConfig, type TableColumn, type FilterFieldConfig, type FilterValues } from '@/components/templates/UniversalListPage'; import { MobileCard } from '@/components/molecules/MobileCard'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { Item, ItemStats, ItemType, Specification, OrderType, ItemStatus } from './types'; import { ITEM_TYPE_OPTIONS, SPECIFICATION_OPTIONS, ORDER_TYPE_OPTIONS, STATUS_OPTIONS, SORT_OPTIONS, ITEMS_PER_PAGE, } from './constants'; import { getItemList, deleteItem, deleteItems, getItemStats, getCategoryOptions } from './actions'; // 테이블 컬럼 정의 const tableColumns: TableColumn[] = [ { key: 'no', label: '번호', className: 'w-[60px] text-center' }, { key: 'itemNumber', label: '품목번호', className: 'w-[100px]' }, { key: 'itemType', label: '품목유형', className: 'w-[90px] text-center' }, { key: 'category', label: '카테고리', className: 'w-[120px]' }, { key: 'itemName', label: '품목명', className: 'min-w-[150px]' }, { key: 'specification', label: '규격', className: 'w-[80px] text-center' }, { key: 'unit', label: '단위', className: 'w-[60px] text-center' }, { key: 'orderType', label: '구분', className: 'w-[100px] text-center' }, { key: 'status', label: '상태', className: 'w-[80px] text-center' }, { key: 'actions', label: '작업', className: 'w-[100px] text-center' }, ]; interface ItemManagementClientProps { initialData?: Item[]; initialStats?: ItemStats; } export default function ItemManagementClient({ initialData = [], initialStats, }: ItemManagementClientProps) { const router = useRouter(); const today = new Date(); // 날짜 상태 (당해년도 기본값) const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd')); const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd')); // 상태 const [items, setItems] = useState(initialData); const [stats, setStats] = useState(initialStats ?? { total: 0, active: 0 }); const [searchValue, setSearchValue] = useState(''); const [itemTypeFilter, setItemTypeFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); const [specificationFilter, setSpecificationFilter] = useState('all'); const [orderTypeFilter, setOrderTypeFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); const [sortBy, setSortBy] = useState<'latest' | 'oldest'>('latest'); const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]); // 카테고리 목록 로드 useEffect(() => { const loadCategories = async () => { const result = await getCategoryOptions(); if (result.success && result.data) { setCategoryOptions(result.data); } }; loadCategories(); }, []); // 데이터 로드 const loadData = useCallback(async () => { setIsLoading(true); try { const [listResult, statsResult] = await Promise.all([ getItemList({ size: 1000, itemType: itemTypeFilter, categoryId: categoryFilter, specification: specificationFilter, orderType: orderTypeFilter, status: statusFilter, sortBy, startDate, endDate, }), getItemStats(), ]); if (listResult.success && listResult.data) { setItems(listResult.data.items); } if (statsResult.success && statsResult.data) { setStats(statsResult.data); } } catch { toast.error('데이터 로드에 실패했습니다.'); } finally { setIsLoading(false); } }, [itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, sortBy, startDate, endDate]); // 초기 데이터가 없으면 로드 useEffect(() => { if (initialData.length === 0) { loadData(); } }, [initialData.length, loadData]); // 필터링된 데이터 const filteredItems = useMemo(() => { return items.filter((item) => { // 품목유형 필터 if (itemTypeFilter !== 'all' && item.itemType !== itemTypeFilter) { return false; } // 카테고리 필터 if (categoryFilter !== 'all' && item.categoryId !== categoryFilter) { return false; } // 규격 필터 if (specificationFilter !== 'all' && item.specification !== specificationFilter) { return false; } // 구분 필터 if (orderTypeFilter !== 'all' && item.orderType !== orderTypeFilter) { return false; } // 상태 필터 if (statusFilter !== 'all' && item.status !== statusFilter) { return false; } // 검색 필터 if (searchValue) { const search = searchValue.toLowerCase(); return ( item.itemNumber.toLowerCase().includes(search) || item.itemName.toLowerCase().includes(search) || item.categoryName.toLowerCase().includes(search) ); } return true; }); }, [items, itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, searchValue]); // 정렬 const sortedItems = useMemo(() => { const sorted = [...filteredItems]; if (sortBy === 'oldest') { sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } else { sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); } return sorted; }, [filteredItems, sortBy]); // 페이지네이션 const totalPages = Math.ceil(sortedItems.length / ITEMS_PER_PAGE); const paginatedData = useMemo(() => { const start = (currentPage - 1) * ITEMS_PER_PAGE; return sortedItems.slice(start, start + ITEMS_PER_PAGE); }, [sortedItems, currentPage]); // 핸들러 const handleSearchChange = useCallback((value: string) => { setSearchValue(value); setCurrentPage(1); }, []); const handleToggleSelection = useCallback((id: string) => { setSelectedItems((prev) => { const newSet = new Set(prev); if (newSet.has(id)) { newSet.delete(id); } else { newSet.add(id); } return newSet; }); }, []); const handleToggleSelectAll = useCallback(() => { if (selectedItems.size === paginatedData.length) { setSelectedItems(new Set()); } else { setSelectedItems(new Set(paginatedData.map((item) => item.id))); } }, [selectedItems.size, paginatedData]); const handleRowClick = useCallback( (item: Item) => { router.push(`/ko/construction/order/base-info/items/${item.id}`); }, [router] ); const handleCreate = useCallback(() => { router.push('/ko/construction/order/base-info/items/new'); }, [router]); const handleEdit = useCallback( (e: React.MouseEvent, itemId: string) => { e.stopPropagation(); router.push(`/ko/construction/order/base-info/items/${itemId}?mode=edit`); }, [router] ); const handleDeleteClick = useCallback((e: React.MouseEvent, itemId: string) => { e.stopPropagation(); setDeleteTargetId(itemId); setDeleteDialogOpen(true); }, []); const handleDeleteConfirm = useCallback(async () => { if (!deleteTargetId) return; setIsLoading(true); try { const result = await deleteItem(deleteTargetId); if (result.success) { toast.success('품목이 삭제되었습니다.'); setItems((prev) => prev.filter((item) => item.id !== deleteTargetId)); setSelectedItems((prev) => { const newSet = new Set(prev); newSet.delete(deleteTargetId); return newSet; }); // 통계 재조회 const statsResult = await getItemStats(); if (statsResult.success && statsResult.data) { setStats(statsResult.data); } } else { toast.error(result.error || '삭제에 실패했습니다.'); } } catch { toast.error('삭제 중 오류가 발생했습니다.'); } finally { setIsLoading(false); setDeleteDialogOpen(false); setDeleteTargetId(null); } }, [deleteTargetId]); const handleBulkDeleteClick = useCallback(() => { if (selectedItems.size === 0) { toast.warning('삭제할 항목을 선택해주세요.'); return; } setBulkDeleteDialogOpen(true); }, [selectedItems.size]); const handleBulkDeleteConfirm = useCallback(async () => { if (selectedItems.size === 0) return; setIsLoading(true); try { const ids = Array.from(selectedItems); const result = await deleteItems(ids); if (result.success) { toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`); await loadData(); setSelectedItems(new Set()); } else { toast.error(result.error || '일괄 삭제에 실패했습니다.'); } } catch { toast.error('일괄 삭제 중 오류가 발생했습니다.'); } finally { setIsLoading(false); setBulkDeleteDialogOpen(false); } }, [selectedItems, loadData]); // 상태 배지 색상 const getStatusBadgeVariant = (status: string) => { switch (status) { case '승인': case '사용': return 'default'; case '작업': return 'secondary'; case '중지': return 'destructive'; default: return 'outline'; } }; // 테이블 행 렌더링 const renderTableRow = useCallback( (item: Item, index: number, globalIndex: number, handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }) => { const { isSelected, onToggle } = handlers; return ( handleRowClick(item)} > e.stopPropagation()}> {globalIndex} {item.itemNumber} {item.itemType} {item.categoryName} {item.itemName} {item.specification} {item.unit} {item.orderType} {item.status} {isSelected && (
)}
); }, [selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick] ); // 모바일 카드 렌더링 const renderMobileCard = useCallback( (item: Item, index: number, globalIndex: number, handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }) => { const { isSelected, onToggle } = handlers; return ( handleRowClick(item)} details={[ { label: '품목유형', value: item.itemType }, { label: '카테고리', value: item.categoryName }, { label: '규격', value: item.specification }, { label: '단위', value: item.unit }, { label: '구분', value: item.orderType }, ]} /> ); }, [handleRowClick] ); // 헤더 액션 제거 - dateRangeSelector와 createButton 사용 // ===== filterConfig 기반 통합 필터 시스템 ===== const filterConfig: FilterFieldConfig[] = useMemo(() => [ { key: 'itemType', label: '품목유형', type: 'single', options: ITEM_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '전체', }, { key: 'category', label: '카테고리', type: 'single', options: categoryOptions.map(c => ({ value: c.id, label: c.name, })), allOptionLabel: '전체', }, { key: 'specification', label: '규격', type: 'single', options: SPECIFICATION_OPTIONS.filter(o => o.value !== 'all').map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '전체', }, { key: 'orderType', label: '구분', type: 'single', options: ORDER_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '전체', }, { key: 'status', label: '상태', type: 'single', options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '전체', }, { key: 'sortBy', label: '정렬', type: 'single', options: SORT_OPTIONS.map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '최신순', }, ], [categoryOptions]); const filterValues: FilterValues = useMemo(() => ({ itemType: itemTypeFilter, category: categoryFilter, specification: specificationFilter, orderType: orderTypeFilter, status: statusFilter, sortBy: sortBy, }), [itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, sortBy]); const handleFilterChange = useCallback((key: string, value: string | string[]) => { switch (key) { case 'itemType': setItemTypeFilter(value as ItemType | 'all'); break; case 'category': setCategoryFilter(value as string); break; case 'specification': setSpecificationFilter(value as Specification | 'all'); break; case 'orderType': setOrderTypeFilter(value as OrderType | 'all'); break; case 'status': setStatusFilter(value as ItemStatus | 'all'); break; case 'sortBy': setSortBy(value as 'latest' | 'oldest'); break; } setCurrentPage(1); }, []); const handleFilterReset = useCallback(() => { setItemTypeFilter('all'); setCategoryFilter('all'); setSpecificationFilter('all'); setOrderTypeFilter('all'); setStatusFilter('all'); setSortBy('latest'); setCurrentPage(1); }, []); // ===== UniversalListPage 설정 ===== const itemManagementConfig: UniversalListConfig = { title: '품목관리', description: '품목을 등록하여 관리합니다.', icon: Package, basePath: '/construction/order/base-info/items', idField: 'id', actions: { getList: async () => ({ success: true, data: items, totalCount: items.length, }), }, columns: tableColumns, stats: [ { label: '전체 품목', value: stats.total, icon: Package, iconColor: 'text-blue-500', }, { label: '사용 품목', value: stats.active, icon: PackageCheck, iconColor: 'text-green-500', }, ], filterConfig: filterConfig, filterTitle: '품목 필터', searchPlaceholder: '품목명, 품목번호, 카테고리 검색', itemsPerPage: ITEMS_PER_PAGE, clientSideFiltering: true, // 날짜 범위 선택기 dateRangeSelector: { enabled: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, // 등록 버튼 createButton: { label: '품목 등록', onClick: handleCreate, icon: Plus, }, renderTableRow, renderMobileCard, renderDialogs: () => ( <> {/* 단일 삭제 다이얼로그 */} 품목 삭제 선택한 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 삭제 {/* 일괄 삭제 다이얼로그 */} 품목 일괄 삭제 선택한 {selectedItems.size}개 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 삭제 ), }; return ( config={itemManagementConfig} initialData={sortedItems} initialTotalCount={sortedItems.length} externalSelection={{ selectedItems, setSelectedItems, }} externalSearch={{ searchValue, setSearchValue: handleSearchChange, }} externalPagination={{ currentPage, setCurrentPage, }} externalFilter={{ filterValues, onFilterChange: handleFilterChange, onFilterReset: handleFilterReset, }} /> ); }