- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가 - 견적확정 후 수주등록 버튼 동적 전환 - 수주등록 품목 개소별(floor+code) 그룹핑 수정 - 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity) - 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용) - 작업지시 상세 개소별/품목별 합산 테이블 추가 - 작업자 화면 API 연동 및 목업 데이터 분리 - 입고관리 완료건 수정, 재고현황 개선
336 lines
11 KiB
TypeScript
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,
|
|
};
|
|
} |