Files
sam-react-prod/src/hooks/useItemList.ts
권혁성 a8591c438e feat: 견적확정 밸리데이션, 수주등록 개소그룹, 작업지시 개선
- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가
- 견적확정 후 수주등록 버튼 동적 전환
- 수주등록 품목 개소별(floor+code) 그룹핑 수정
- 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity)
- 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용)
- 작업지시 상세 개소별/품목별 합산 테이블 추가
- 작업자 화면 API 연동 및 목업 데이터 분리
- 입고관리 완료건 수정, 재고현황 개선
2026-02-07 03:27:23 +09:00

336 lines
11 KiB
TypeScript

/**
* 품목 목록 및 테이블 컬럼 관리 훅
*
* - 품목 목록 API 조회 (서버 사이드 검색/필터링)
* - custom-tabs 기반 동적 테이블 컬럼
*/
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ItemMaster, ItemType, PartType } from '@/types/item';
import type { CustomTabResponse, TabColumnResponse } from '@/types/item-master-api';
import { itemMasterApi } from '@/lib/api/item-master';
// 기본 테이블 컬럼 설정 (API 응답 실패 시 폴백용)
const defaultTableColumns = [
{ key: 'item_code', label: '품목코드', visible: true, order: 0 },
{ key: 'item_type', label: '품목유형', visible: true, order: 1 },
{ key: 'item_name', label: '품목명', visible: true, order: 2 },
{ key: 'specification', label: '규격', visible: true, order: 3 },
{ key: 'unit', label: '단위', visible: true, order: 4 },
{ key: 'is_active', label: '상태', visible: true, order: 5 },
];
export interface TableColumn {
key: string;
label: string;
visible: boolean;
order: number;
}
export interface ItemListFilters {
search?: string;
type?: string;
page?: number;
size?: number;
}
export interface PaginationInfo {
currentPage: number;
totalPages: number;
totalItems: number;
perPage: number;
}
export interface ItemStats {
total: number;
byType: Record<string, number>;
}
export interface TotalStats {
totalAll: number; // 전체 품목 수 (필터 무관)
totalFG: number; // 제품 수
totalPT: number; // 부품 수
totalSM: number; // 부자재 수
totalRM: number; // 원자재 수
totalCS: number; // 소모품 수
}
export interface UseItemListResult {
// 데이터
items: ItemMaster[];
tabs: CustomTabResponse[];
columns: TableColumn[];
pagination: PaginationInfo;
totalStats: TotalStats; // 필터 무관한 전체 통계
// 상태
isLoading: boolean;
isSearching: boolean;
error: string | null;
// 액션
refresh: () => Promise<void>;
search: (filters: ItemListFilters) => Promise<void>;
setActiveTab: (tabId: number | null) => void;
activeTabId: number | null;
}
export function useItemList(): UseItemListResult {
// 데이터 상태
const [items, setItems] = useState<ItemMaster[]>([]);
const [tabs, setTabs] = useState<CustomTabResponse[]>([]);
const [tabColumns, setTabColumns] = useState<Record<number, TabColumnResponse[]>>({});
const [pagination, setPagination] = useState<PaginationInfo>({
currentPage: 1,
totalPages: 1,
totalItems: 0,
perPage: 20,
});
// 전체 통계 (필터와 무관하게 유지)
const [totalStats, setTotalStats] = useState<TotalStats>({
totalAll: 0,
totalFG: 0,
totalPT: 0,
totalSM: 0,
totalRM: 0,
totalCS: 0,
});
// UI 상태
const [activeTabId, setActiveTabId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
// 현재 필터 상태 (ref로 관리하여 불필요한 리렌더링 방지)
const currentFilters = useRef<ItemListFilters>({});
// 현재 탭의 컬럼 설정
const columns: TableColumn[] = activeTabId && tabColumns[activeTabId]
? tabColumns[activeTabId].map((col, idx) => ({
key: col.key,
label: col.label,
visible: col.visible,
order: col.order ?? idx,
}))
: defaultTableColumns;
// API 응답을 프론트엔드 타입으로 변환하는 함수
const mapItemResponse = (item: Record<string, unknown>): ItemMaster => {
// 디버깅: API 응답에서 id 필드 확인
const itemId = item.id ?? item.item_id ?? item.ID;
if (!itemId) {
console.warn('[useItemList] id 필드 누락:', {
availableKeys: Object.keys(item),
item
});
}
return {
id: String(itemId ?? ''),
itemCode: (item.code ?? item.item_code ?? '') as string,
itemName: (item.name ?? item.item_name ?? '') as string,
itemType: (item.type_code ?? item.item_type ?? '') as ItemType,
partType: item.part_type as PartType | undefined,
unit: (item.unit ?? '') as string,
specification: ((item.details as Record<string, unknown>)?.specification ?? item.specification ?? '') as string,
// is_active가 null/undefined면 deleted_at 기준으로 판단 (삭제 안됐으면 활성)
// deleted_at이 없거나 null이면 활성, 값이 있으면 비활성
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 fetchTotalStats = useCallback(async () => {
try {
// 각 유형별로 병렬 조회
const [allResponse, fgResponse, ptResponse, smResponse, rmResponse, csResponse] = await Promise.all([
fetch('/api/proxy/items?group_id=1&size=1'), // 전체 (품목관리 그룹)
fetch('/api/proxy/items?type=FG&size=1'), // 제품
fetch('/api/proxy/items?type=PT&size=1'), // 부품
fetch('/api/proxy/items?type=SM&size=1'), // 부자재
fetch('/api/proxy/items?type=RM&size=1'), // 원자재
fetch('/api/proxy/items?type=CS&size=1'), // 소모품
]);
const [allResult, fgResult, ptResult, smResult, rmResult, csResult] = await Promise.all([
allResponse.json(),
fgResponse.json(),
ptResponse.json(),
smResponse.json(),
rmResponse.json(),
csResponse.json(),
]);
return {
totalAll: allResult.data?.total ?? 0,
totalFG: fgResult.data?.total ?? 0,
totalPT: ptResult.data?.total ?? 0,
totalSM: smResult.data?.total ?? 0,
totalRM: rmResult.data?.total ?? 0,
totalCS: csResult.data?.total ?? 0,
};
} catch (err) {
console.error('전체 통계 조회 실패:', err);
return { totalAll: 0, totalFG: 0, totalPT: 0, totalSM: 0, totalRM: 0, totalCS: 0 };
}
}, []);
// 품목 목록 조회 (필터 적용)
const fetchItems = useCallback(async (filters: ItemListFilters = {}) => {
// URL 쿼리 파라미터 생성
const params = new URLSearchParams();
if (filters.search && filters.search.trim()) {
params.append('search', filters.search.trim());
}
// 타입별 조회 vs 전체 조회
if (filters.type && filters.type !== 'all') {
// 특정 타입 조회: type 파라미터 사용
params.append('type', filters.type);
} else {
// 전체 조회: group_id=1 (품목관리 그룹)
params.append('group_id', '1');
}
if (filters.page) {
params.append('page', String(filters.page));
}
if (filters.size) {
params.append('size', String(filters.size));
}
const queryString = params.toString();
const url = `/api/proxy/items${queryString ? `?${queryString}` : ''}`;
const itemsResponse = await fetch(url);
const itemsResult = await itemsResponse.json();
if (itemsResult.success) {
// API 응답 구조: { success, data: { data: [...], current_page, total, per_page, last_page } }
const paginatedData = itemsResult.data;
const rawItems = paginatedData?.data ?? paginatedData ?? [];
if (!Array.isArray(rawItems)) {
return { items: [], pagination: { currentPage: 1, totalPages: 1, totalItems: 0, perPage: 20 } };
}
const mappedItems = rawItems.map(mapItemResponse);
return {
items: mappedItems,
pagination: {
currentPage: paginatedData?.current_page ?? 1,
totalPages: paginatedData?.last_page ?? 1,
totalItems: paginatedData?.total ?? mappedItems.length,
perPage: paginatedData?.per_page ?? 20,
},
};
} else {
throw new Error(itemsResult.message || '품목 목록을 불러올 수 없습니다.');
}
}, []);
// 초기 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// 1. 품목기준관리 init API 호출 (탭 + 컬럼 설정 포함)
const initData = await itemMasterApi.init();
if (initData.customTabs) {
setTabs(initData.customTabs);
// 기본 탭 설정
const defaultTab = initData.customTabs.find((t) => t.is_default);
if (defaultTab) {
setActiveTabId(defaultTab.id);
} else if (initData.customTabs.length > 0) {
setActiveTabId(initData.customTabs[0].id);
}
}
if (initData.tabColumns) {
setTabColumns(initData.tabColumns);
}
// 2. 품목 목록 API 호출 + 전체 통계 병렬 조회
const [result, stats] = await Promise.all([
fetchItems(currentFilters.current),
fetchTotalStats(),
]);
setItems(result.items);
setPagination(result.pagination);
setTotalStats(stats);
} catch (err) {
console.error('품목 목록 로드 실패:', err);
// API 실패 시 에러만 표시
setItems([]);
setTabs([]);
setTabColumns({});
setPagination({ currentPage: 1, totalPages: 1, totalItems: 0, perPage: 20 });
setError(err instanceof Error ? err.message : '데이터 로드 실패');
} finally {
setIsLoading(false);
}
}, [fetchItems, fetchTotalStats]);
// 검색/필터 적용
const search = useCallback(async (filters: ItemListFilters) => {
setIsSearching(true);
setError(null);
currentFilters.current = { ...currentFilters.current, ...filters };
try {
const result = await fetchItems(currentFilters.current);
setItems(result.items);
setPagination(result.pagination);
} catch (err) {
console.error('품목 검색 실패:', err);
setError(err instanceof Error ? err.message : '검색 실패');
} finally {
setIsSearching(false);
}
}, [fetchItems]);
// 초기 로드
useEffect(() => {
loadData();
}, [loadData]);
// 탭 변경
const setActiveTab = useCallback((tabId: number | null) => {
setActiveTabId(tabId);
}, []);
return {
items,
tabs,
columns,
pagination,
totalStats,
isLoading,
isSearching,
error,
refresh: loadData,
search,
setActiveTab,
activeTabId,
};
}