feat(WEB): 자재/영업/품질 모듈 기능 개선 및 문서 컴포넌트 추가

- 입고관리: 상세/목록 UI 개선, actions 로직 강화
- 재고현황: 상세/목록 개선, StockAuditModal 신규 추가
- 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화
- 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가
- 견적: QuoteTransactionModal 기능 개선
- 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선
- UniversalListPage: 템플릿 기능 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-28 21:15:25 +09:00
parent 79b39a3ef6
commit 1f640622e0
23 changed files with 4683 additions and 1946 deletions

View File

@@ -50,7 +50,8 @@ export function UniversalListPage<T>({
// UI 상태
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(!initialData);
const [searchValue, setSearchValue] = useState('');
const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영)
const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
const [activeTab, setActiveTab] = useState(
@@ -86,6 +87,15 @@ export function UniversalListPage<T>({
const [serverTotalCount, setServerTotalCount] = useState<number>(initialTotalCount || 0);
const [serverTotalPages, setServerTotalPages] = useState<number>(1);
// ===== 검색 Debounce (300ms) =====
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchValue(searchValue);
}, 300);
return () => clearTimeout(timer);
}, [searchValue]);
// ===== ID 추출 헬퍼 =====
const getItemId = useCallback(
(item: T): string => {
@@ -115,10 +125,10 @@ export function UniversalListPage<T>({
filtered = filtered.filter((item) => config.tabFilter!(item, activeTab));
}
// 검색 필터
if (searchValue && config.searchFilter) {
// 검색 필터 (debounced 값 사용)
if (debouncedSearchValue && config.searchFilter) {
filtered = filtered.filter((item) =>
config.searchFilter!(item, searchValue)
config.searchFilter!(item, debouncedSearchValue)
);
}
@@ -168,7 +178,7 @@ export function UniversalListPage<T>({
}
return filtered;
}, [rawData, activeTab, searchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]);
}, [rawData, activeTab, debouncedSearchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]);
// 클라이언트 사이드 페이지네이션
const paginatedData = useMemo(() => {
@@ -222,7 +232,7 @@ export function UniversalListPage<T>({
: {
page: currentPage,
pageSize: itemsPerPage,
search: searchValue,
search: debouncedSearchValue,
filters,
tab: activeTab,
}
@@ -249,7 +259,7 @@ export function UniversalListPage<T>({
setIsLoading(false);
setIsMobileLoading(false);
}
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, debouncedSearchValue, filters, activeTab]);
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
useEffect(() => {
@@ -289,6 +299,12 @@ export function UniversalListPage<T>({
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
const [prevPage, setPrevPage] = useState(1);
// 날짜 범위 변경 감지용 (서버 사이드 필터링에서 날짜 변경 시 데이터 새로고침)
const dateRangeKey = config.dateRangeSelector?.enabled
? `${config.dateRangeSelector.startDate || ''}-${config.dateRangeSelector.endDate || ''}`
: '';
useEffect(() => {
if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
@@ -296,7 +312,7 @@ export function UniversalListPage<T>({
fetchData(isMobileAppend);
setPrevPage(currentPage);
}
}, [currentPage, searchValue, filters, activeTab]);
}, [currentPage, debouncedSearchValue, filters, activeTab, dateRangeKey]);
// 동적 탭 로딩
useEffect(() => {
@@ -459,12 +475,14 @@ export function UniversalListPage<T>({
// ===== 검색 핸들러 =====
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
setSelectedItems(new Set());
// 외부 콜백 호출 (서버 사이드 검색용)
onSearchChange?.(value);
}, [onSearchChange]);
setSearchValue(value); // UI 즉시 반영
// 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리)
}, []);
// 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용)
useEffect(() => {
onSearchChange?.(debouncedSearchValue);
}, [debouncedSearchValue, onSearchChange]);
// ===== 필터 핸들러 =====
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
@@ -514,7 +532,7 @@ export function UniversalListPage<T>({
const additionalParams = fetchAllParams({
activeTab,
filters,
searchValue,
searchValue: debouncedSearchValue,
});
Object.entries(additionalParams).forEach(([key, value]) => {
if (value) params.append(key, value);
@@ -559,7 +577,7 @@ export function UniversalListPage<T>({
} finally {
setIsExcelDownloading(false);
}
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, searchValue]);
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, debouncedSearchValue]);
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = useCallback(() => {