/** * 품목 목록 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 { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; import { Search, Plus, Edit, Trash2, Package, FileDown, Upload } from 'lucide-react'; import { downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download'; 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/MobileCard'; // 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]); // 유형 변경 핸들러 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(`/production/screen-production/${encodeURIComponent(itemCode)}?mode=view&type=${itemType}&id=${itemId}`); }; const handleEdit = (itemCode: string, itemType: string, itemId: string) => { // itemType을 query param으로 전달 (Materials 조회를 위해) router.push(`/production/screen-production/${encodeURIComponent(itemCode)}?mode=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 excelColumns: ExcelColumn[] = [ { header: '품목코드', key: 'itemCode', width: 15 }, { header: '품목유형', key: 'itemType', width: 10, transform: (v) => ITEM_TYPE_LABELS[v as keyof typeof ITEM_TYPE_LABELS] || String(v) }, { header: '품목명', key: 'itemName', width: 30 }, { header: '규격', key: 'specification', width: 20 }, { header: '단위', key: 'unit', width: 8 }, { header: '대분류', key: 'category1', width: 12 }, { header: '중분류', key: 'category2', width: 12 }, { header: '소분류', key: 'category3', width: 12 }, { header: '구매단가', key: 'purchasePrice', width: 12 }, { header: '판매단가', key: 'salesPrice', width: 12 }, { header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' }, ]; // API 응답을 ItemMaster 타입으로 변환 (엑셀 다운로드용) const mapItemResponse = (result: unknown): ItemMaster[] => { const data = result as { data?: { data?: Record[] }; }; const rawItems = data.data?.data ?? []; return rawItems.map((item: Record) => ({ id: String(item.id ?? item.item_id ?? ''), itemCode: (item.code ?? item.item_code ?? '') as string, itemName: (item.name ?? item.item_name ?? '') as string, itemType: (item.type_code ?? item.item_type ?? '') as ItemMaster['itemType'], partType: item.part_type as ItemMaster['partType'], unit: (item.unit ?? '') as string, specification: (item.specification ?? '') as string, isActive: item.is_active != null ? Boolean(item.is_active) : !item.deleted_at, category1: (item.category1 ?? '') as string, category2: (item.category2 ?? '') as string, category3: (item.category3 ?? '') as string, salesPrice: (item.sales_price ?? 0) as number, purchasePrice: (item.purchase_price ?? 0) as number, currentRevision: (item.current_revision ?? 0) as number, isFinal: Boolean(item.is_final ?? false), createdAt: (item.created_at ?? '') as string, updatedAt: (item.updated_at ?? '') as string, })); }; // 업로드용 템플릿 컬럼 정의 const templateColumns: TemplateColumn[] = [ { header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001', description: '고유 코드', width: 15 }, { header: '품목유형', key: 'itemType', required: true, type: 'select', options: ['FG', 'PT', 'SM', 'RM', 'CS'], sampleValue: 'FG', description: 'FG:제품/PT:부품/SM:부자재/RM:원자재/CS:소모품', width: 12 }, { header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어 본체', width: 25 }, { header: '규격', key: 'specification', type: 'text', sampleValue: '1800x2100', width: 15 }, { header: '단위', key: 'unit', required: true, type: 'select', options: ['EA', 'SET', 'KG', 'M', 'M2', 'BOX'], sampleValue: 'EA', width: 10 }, { header: '대분류', key: 'category1', type: 'text', sampleValue: '스크린도어', width: 12 }, { header: '중분류', key: 'category2', type: 'text', sampleValue: '본체류', width: 12 }, { header: '소분류', key: 'category3', type: 'text', sampleValue: '프레임', width: 12 }, { header: '구매단가', key: 'purchasePrice', type: 'number', sampleValue: 150000, width: 12 }, { header: '판매단가', key: 'salesPrice', type: 'number', sampleValue: 200000, width: 12 }, { header: '활성상태', key: 'isActive', type: 'boolean', sampleValue: 'Y', description: 'Y:활성/N:비활성', width: 10 }, ]; // 양식 다운로드 const handleTemplateDownload = () => { downloadExcelTemplate({ columns: templateColumns, filename: '품목등록_양식', sheetName: '품목등록', includeSampleRow: true, includeGuideRow: true, }); }; // 파일 업로드 input ref const fileInputRef = useRef(null); // 양식 업로드 핸들러 const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; try { const result = await parseExcelFile(file, { columns: templateColumns, skipRows: 2, // 헤더 + 안내 행 스킵 }); if (!result.success || result.errors.length > 0) { const errorMessages = result.errors.slice(0, 5).map( (err) => `${err.row}행: ${err.message}` ).join('\n'); alert(`업로드 오류:\n${errorMessages}${result.errors.length > 5 ? `\n... 외 ${result.errors.length - 5}건` : ''}`); return; } if (result.data.length === 0) { alert('업로드할 데이터가 없습니다.'); return; } // TODO: 실제 API 호출로 데이터 저장 // 지금은 파싱 결과만 확인 console.log('[Excel Upload] 파싱 결과:', result.data); alert(`${result.data.length}건의 데이터가 파싱되었습니다.\n(실제 등록 기능은 추후 구현 예정)`); } catch (error) { console.error('[Excel Upload] 오류:', error); alert('파일 업로드에 실패했습니다.'); } finally { // input 초기화 (같은 파일 재선택 가능하도록) if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; // 탭 옵션 (품목 유형별) 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', // 테이블 컬럼 (sortable: true로 정렬 가능) 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: 'isActive', 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('/production/screen-production?mode=new'), icon: Plus, }, // 엑셀 다운로드 설정 (공통 기능) excelDownload: { columns: excelColumns, filename: '품목목록', sheetName: '품목', fetchAllUrl: '/api/proxy/items', fetchAllParams: ({ activeTab }): Record => { // 현재 선택된 타입 필터 적용 if (activeTab && activeTab !== 'all') { return { type: activeTab }; } return { group_id: '1' }; // 품목관리 그룹 }, mapResponse: mapItemResponse, }, // 헤더 액션 (양식 다운로드/업로드 - 추후 활성화) // headerActions: () => ( //
// //
// ), // 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 ( <> {/* 숨겨진 파일 업로드 input */} {/* 개별 삭제 확인 다이얼로그 */} 품목 "{itemToDelete?.code}"을(를) 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다. } /> ); }