/** * 품목 목록 Client Component - UniversalListPage 마이그레이션 * * 품목기준관리 API 연동 * - 품목 목록: useItemList 훅으로 API 조회 (서버 사이드 검색/필터링) * - UniversalListPage 기반 공통 UI 적용 */ 'use client'; import { useState, useEffect, useRef, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import type { ItemMaster } from '@/types/item'; import { ITEM_TYPE_LABELS } from '@/types/item'; import { getItemTypeStyle } from '@/lib/utils/status-config'; import { useCommonCodes } from '@/hooks/useCommonCodes'; 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 { Plus, 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'; import { toast } from 'sonner'; // 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 색상 반환 * - 공통 유틸 getItemTypeStyle 사용 */ function getItemTypeBadge(itemType: string) { 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 handleSearchChange = (value: string) => { setSearchTerm(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 { // 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용 // /products/materials 라우트 삭제됨 const deleteUrl = `/api/proxy/items/${itemToDelete.id}?item_type=${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(); if (result.success) { refresh(); } else { throw new Error(result.message || '삭제에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('품목 삭제 실패:', error); toast.error(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) { toast.success(`${successCount}개 품목이 삭제되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`); refresh(); } else { toast.error('품목 삭제에 실패했습니다.'); } }; // 엑셀 다운로드용 컬럼 정의 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 = async () => { await 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'); toast.error('업로드 오류', { description: `${errorMessages}${result.errors.length > 5 ? ` (외 ${result.errors.length - 5}건)` : ''}` }); return; } if (result.data.length === 0) { toast.warning('업로드할 데이터가 없습니다.'); return; } // TODO: 실제 API 호출로 데이터 저장 // 지금은 파싱 결과만 확인 toast.info(`${result.data.length}건의 데이터가 파싱되었습니다. (실제 등록 기능은 추후 구현 예정)`); } catch (error) { console.error('[Excel Upload] 오류:', error); toast.error('파일 업로드에 실패했습니다.'); } finally { // input 초기화 (같은 파일 재선택 가능하도록) if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; // 품목 유형 공통코드 const { codes: itemTypeCodes } = useCommonCodes('item_type'); // 코드별 색상 매핑 const codeColorMap: Record = { FG: 'purple', PT: 'orange', SM: 'green', RM: 'blue', CS: 'gray', }; const codeIconColorMap: Record = { FG: 'text-purple-600', PT: 'text-orange-600', SM: 'text-green-600', RM: 'text-cyan-600', CS: 'text-gray-600', }; // 코드별 통계 매핑 const codeCountMap: Record = { FG: totalStats.totalFG, PT: totalStats.totalPT, SM: totalStats.totalSM, RM: totalStats.totalRM, CS: totalStats.totalCS, }; // 탭 옵션 (공통코드 기반 동적 생성) const tabs: TabOption[] = useMemo(() => { const dynamicTabs: TabOption[] = itemTypeCodes.map((code) => ({ value: code.code, label: code.name, count: codeCountMap[code.code] ?? 0, color: codeColorMap[code.code] ?? 'gray', })); return [ { value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' }, ...dynamicTabs, ]; }, [itemTypeCodes, totalStats]); // 통계 카드 (공통코드 기반 동적 생성) const stats: StatCard[] = useMemo(() => { const dynamicStats: StatCard[] = itemTypeCodes.map((code) => ({ label: code.name, value: codeCountMap[code.code] ?? 0, icon: Package, iconColor: codeIconColorMap[code.code] ?? 'text-gray-600', })); return [ { label: '전체 품목', value: totalStats.totalAll, icon: Package, iconColor: 'text-blue-600' }, ...dynamicStats, ]; }, [itemTypeCodes, totalStats]); // 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]' }, ], // 클라이언트 사이드 필터링 (외부 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 ( handleView(item.itemCode, item.itemType, item.id)}> e.stopPropagation()}> {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 && ( )}
} /> ); }, }; return ( <> {/* 숨겨진 파일 업로드 input */} {/* 개별 삭제 확인 다이얼로그 */} 품목 "{itemToDelete?.code}"을(를) 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다. } /> ); }