/** * 품목 목록 Client Component - UniversalListPage 마이그레이션 * * 품목기준관리 API 연동 * - 품목 목록: useItemList 훅으로 API 조회 (서버 사이드 검색/필터링) * - UniversalListPage 기반 공통 UI 적용 */ 'use client'; import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import type { ItemMaster } from '@/types/item'; import { ITEM_TYPE_LABELS } from '@/types/item'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Search, Plus, Edit, Trash2, Package } from 'lucide-react'; import { TableLoadingSpinner } from '@/components/ui/loading-spinner'; import { useItemList } from '@/hooks/useItemList'; import { handleApiError } from '@/lib/api/error-handler'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { UniversalListPage, type UniversalListConfig, type SelectionHandlers, type RowClickHandlers, type TabOption, type StatCard, } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; // Debounce 훅 function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } /** * 품목 유형별 Badge 색상 반환 */ function getItemTypeBadge(itemType: string) { const badges: Record = { FG: { variant: 'default', className: 'bg-purple-100 text-purple-700 border-purple-200' }, PT: { variant: 'default', className: 'bg-orange-100 text-orange-700 border-orange-200' }, SM: { variant: 'default', className: 'bg-green-100 text-green-700 border-green-200' }, RM: { variant: 'default', className: 'bg-blue-100 text-blue-700 border-blue-200' }, CS: { variant: 'default', className: 'bg-gray-100 text-gray-700 border-gray-200' }, }; const config = badges[itemType] || { variant: 'outline' as const, className: '' }; return ( {ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]} ); } /** * 부품 유형 라벨 반환 */ function getPartTypeLabel(partType: string | undefined): string { if (!partType) return ''; const labels: Record = { ASSEMBLY: '조립', BENDING: '절곡', PURCHASED: '구매', }; return labels[partType] || ''; } export default function ItemListClient() { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(''); const [selectedType, setSelectedType] = useState('all'); // 삭제 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string; itemType: string } | null>(null); // Materials 타입 (SM, RM, CS는 Material 테이블 사용) const MATERIAL_TYPES = ['SM', 'RM', 'CS']; // API에서 품목 목록 및 테이블 컬럼 조회 (서버 사이드 검색/필터링) const { items, pagination, totalStats, isLoading, isSearching, refresh, search, } = useItemList(); // 디바운스된 검색어 (300ms 딜레이) const debouncedSearchTerm = useDebounce(searchTerm, 300); // 검색 상태 추적용 ref const isFirstRender = useRef(true); // 디바운스된 검색어 변경 시 서버 검색 실행 useEffect(() => { // 첫 렌더링에서는 검색하지 않음 if (isFirstRender.current) { isFirstRender.current = false; return; } search({ search: debouncedSearchTerm, type: selectedType, page: 1, }); }, [debouncedSearchTerm, selectedType, search]); // 로딩 상태 if (isLoading) { return ; } // 유형 변경 핸들러 const handleTypeChange = (value: string) => { setSelectedType(value); }; // 페이지 변경 핸들러 const handlePageChange = (page: number) => { search({ search: searchTerm, type: selectedType, page, }); }; const handleView = (itemCode: string, itemType: string, itemId: string) => { // itemType을 query param으로 전달 (Materials 조회를 위해) router.push(`/items/${encodeURIComponent(itemCode)}?type=${itemType}&id=${itemId}`); }; const handleEdit = (itemCode: string, itemType: string, itemId: string) => { // itemType을 query param으로 전달 (Materials 조회를 위해) router.push(`/items/${encodeURIComponent(itemCode)}/edit?type=${itemType}&id=${itemId}`); }; // 삭제 확인 다이얼로그 열기 const openDeleteDialog = (itemId: string, itemCode: string, itemType: string) => { setItemToDelete({ id: itemId, code: itemCode, itemType }); setDeleteDialogOpen(true); }; // 삭제 실행 const handleConfirmDelete = async () => { if (!itemToDelete) return; try { console.log('[Delete] 삭제 요청:', itemToDelete); // 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용 // /products/materials 라우트 삭제됨 const deleteUrl = `/api/proxy/items/${itemToDelete.id}?item_type=${itemToDelete.itemType}`; console.log('[Delete] URL:', deleteUrl, '(itemType:', itemToDelete.itemType, ')'); const response = await fetch(deleteUrl, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, }); // 🚨 401 인증 에러 시 자동 로그인 페이지 리다이렉트 if (!response.ok) { await handleApiError(response); } const result = await response.json(); console.log('[Delete] 응답:', { status: response.status, result }); if (result.success) { refresh(); } else { throw new Error(result.message || '삭제에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('품목 삭제 실패:', error); alert(error instanceof Error ? error.message : '품목 삭제에 실패했습니다.'); } finally { setDeleteDialogOpen(false); setItemToDelete(null); } }; // 일괄 삭제 핸들러 // 2025-12-15: 백엔드 동적 테이블 라우팅으로 모든 품목에 item_type 필수 const handleBulkDelete = async (selectedIds: string[]) => { let successCount = 0; let failCount = 0; for (const id of selectedIds) { try { // 해당 품목의 itemType 찾기 const item = items.find((i) => i.id === id); // 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용 const deleteUrl = `/api/proxy/items/${id}?item_type=${item?.itemType}`; const response = await fetch(deleteUrl, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }); // 🚨 401 인증 에러 시 자동 로그인 페이지 리다이렉트 if (response.status === 401) { await handleApiError(response); return; // 리다이렉트 후 중단 } const result = await response.json(); if (response.ok && result.success) { successCount++; } else { failCount++; } } catch { failCount++; } } if (successCount > 0) { alert(`${successCount}개 품목이 삭제되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`); refresh(); } else { alert('품목 삭제에 실패했습니다.'); } }; // 탭 옵션 (품목 유형별) const tabs: TabOption[] = [ { value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' }, { value: 'FG', label: '제품', count: totalStats.totalFG, color: 'purple' }, { value: 'PT', label: '부품', count: totalStats.totalPT, color: 'orange' }, { value: 'SM', label: '부자재', count: totalStats.totalSM, color: 'green' }, { value: 'RM', label: '원자재', count: totalStats.totalRM, color: 'blue' }, { value: 'CS', label: '소모품', count: totalStats.totalCS, color: 'gray' }, ]; // 통계 카드 (전체 통계) const stats: StatCard[] = [ { label: '전체 품목', value: totalStats.totalAll, icon: Package, iconColor: 'text-blue-600' }, { label: '제품', value: totalStats.totalFG, icon: Package, iconColor: 'text-purple-600' }, { label: '부품', value: totalStats.totalPT, icon: Package, iconColor: 'text-orange-600' }, { label: '부자재', value: totalStats.totalSM, icon: Package, iconColor: 'text-green-600' }, { label: '원자재', value: totalStats.totalRM, icon: Package, iconColor: 'text-cyan-600' }, { label: '소모품', value: totalStats.totalCS, icon: Package, iconColor: 'text-gray-600' }, ]; // UniversalListPage Config const config: UniversalListConfig = { // 페이지 기본 정보 title: '품목 관리', description: '제품, 부품, 부자재, 원자재, 소모품 등록 및 관리', icon: Package, basePath: '/items', // ID 추출 idField: 'id', // 테이블 컬럼 columns: [ { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, { key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' }, { key: 'itemType', label: '품목유형', className: 'min-w-[100px]' }, { key: 'itemName', label: '품목명', className: 'min-w-[150px]' }, { key: 'specification', label: '규격', className: 'min-w-[100px]' }, { key: 'unit', label: '단위', className: 'min-w-[60px]' }, { key: 'status', label: '품목상태', className: 'min-w-[80px]' }, { key: 'actions', label: '작업', className: 'w-[120px] text-right' }, ], // 클라이언트 사이드 필터링 (외부 useItemList 훅 사용) clientSideFiltering: true, itemsPerPage: pagination.perPage, // 검색 searchPlaceholder: '품목코드, 품목명, 규격 검색...', // 탭 설정 tabs, defaultTab: 'all', // 통계 카드 stats, // 등록 버튼 (createButton 사용 - headerActions 대신) createButton: { label: '품목 등록', onClick: () => router.push('/items/create'), icon: Plus, }, // API 액션 (일괄 삭제 포함) actions: { getList: async () => ({ success: true, data: items }), deleteBulk: async (ids: string[]) => { await handleBulkDelete(ids); return { success: true }; }, }, // 테이블 행 렌더링 renderTableRow: ( item: ItemMaster, index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers ) => { return ( {globalIndex} {item.itemCode || '-'}
{getItemTypeBadge(item.itemType)} {item.itemType === 'PT' && item.partType && ( {getPartTypeLabel(item.partType)} )}
{item.itemName} {item.specification || '-'} {item.unit || '-'} {item.isActive ? '활성' : '비활성'}
); }, // 모바일 카드 렌더링 renderMobileCard: ( item: ItemMaster, index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers ) => { return ( {item.itemCode} {getItemTypeBadge(item.itemType)} {item.itemType === 'PT' && item.partType && ( {getPartTypeLabel(item.partType)} )} } statusBadge={ {item.isActive ? '활성' : '비활성'} } isSelected={handlers.isSelected} onToggleSelection={handlers.onToggle} onClick={() => handleView(item.itemCode, item.itemType, item.id)} infoGrid={
{item.specification && ( )} {item.unit && ( )}
} actions={ handlers.isSelected ? (
) : undefined } /> ); }, }; return ( <> {/* 개별 삭제 확인 다이얼로그 */} 품목 삭제 품목 "{itemToDelete?.code}"을(를) 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
취소 삭제
); }