Files
sam-react-prod/src/components/business/construction/item-management/ItemManagementClient.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

639 lines
20 KiB
TypeScript

'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<Item[]>(initialData);
const [stats, setStats] = useState<ItemStats>(initialStats ?? { total: 0, active: 0 });
const [searchValue, setSearchValue] = useState('');
const [itemTypeFilter, setItemTypeFilter] = useState<ItemType | 'all'>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [specificationFilter, setSpecificationFilter] = useState<Specification | 'all'>('all');
const [orderTypeFilter, setOrderTypeFilter] = useState<OrderType | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<ItemStatus | 'all'>('all');
const [sortBy, setSortBy] = useState<'latest' | 'oldest'>('latest');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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 (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.itemNumber}</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{item.itemType}</Badge>
</TableCell>
<TableCell>{item.categoryName}</TableCell>
<TableCell className="font-medium">{item.itemName}</TableCell>
<TableCell className="text-center">{item.specification}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">
<Badge variant="outline">{item.orderType}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={getStatusBadgeVariant(item.status)}>{item.status}</Badge>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, item.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[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 (
<MobileCard
title={item.itemName}
subtitle={item.itemNumber}
badge={item.status}
badgeVariant={getStatusBadgeVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline'}
isSelected={isSelected}
onToggle={onToggle}
onClick={() => 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<Item> = {
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: () => (
<>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
),
};
return (
<UniversalListPage<Item>
config={itemManagementConfig}
initialData={sortedItems}
initialTotalCount={sortedItems.length}
externalSelection={{
selectedItems,
setSelectedItems,
}}
externalSearch={{
searchValue,
setSearchValue: handleSearchChange,
}}
externalPagination={{
currentPage,
setCurrentPage,
}}
externalFilter={{
filterValues,
onFilterChange: handleFilterChange,
onFilterReset: handleFilterReset,
}}
/>
);
}