Files
sam-react-prod/src/components/material/StockStatus/StockStatusList.tsx
유병철 c1b63b850a feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리:
- 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장
- 재고현황 컴포넌트 리팩토링

출고관리:
- 출하관리 생성/수정/목록/상세 개선
- 차량배차관리 상세/수정/목록 기능 보강

생산관리:
- 작업지시서 WIP 생산 모달 신규 추가
- 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가
- 작업자화면 기능 대폭 확장 (카드/목록 개선)
- 검사성적서 모달 개선

품질관리:
- 실적보고서 관리 페이지 신규 추가
- 검사관리 문서/타입/목데이터 개선

단가관리:
- 단가배포 페이지 및 컴포넌트 신규 추가
- 단가표 관리 페이지 및 컴포넌트 신규 추가

공통:
- 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard)
- 메뉴 폴링 훅 개선, 레이아웃 수정
- 모바일 줌/패닝 CSS 수정
- locale 유틸 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:46:19 +09:00

446 lines
15 KiB
TypeScript

'use client';
/**
* 재고현황 목록 - UniversalListPage 마이그레이션
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 서버 사이드 페이지네이션 (getStocks API)
* - 통계 카드 (getStockStats API)
* - 품목유형별 탭 필터 (getStockStatsByType API)
* - 테이블 푸터 (요약 정보)
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Package,
CheckCircle2,
AlertCircle,
Eye,
AlertTriangle,
} from 'lucide-react';
import type { ExcelColumn } from '@/lib/utils/excel-download';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getStocks, getStockStats } from './actions';
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
export function StockStatusList() {
const router = useRouter();
// ===== 통계 (외부 관리) =====
const [stockStats, setStockStats] = useState<StockStats | null>(null);
// ===== 날짜 범위 상태 =====
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const [startDate, setStartDate] = useState<string>(firstDayOfMonth.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
// ===== 데이터 상태 (수주관리 패턴) =====
const [stocks, setStocks] = useState<StockItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// ===== 검색 및 필터 상태 =====
const [searchTerm, setSearchTerm] = useState('');
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
useStatus: 'all',
});
// 데이터 로드 함수
const loadData = useCallback(async () => {
try {
setIsLoading(true);
const [stocksResult, statsResult] = await Promise.all([
getStocks({
page: 1,
perPage: 9999, // 전체 데이터 로드 (클라이언트 사이드 필터링)
startDate,
endDate,
}),
getStockStats(),
]);
if (stocksResult.success && stocksResult.data) {
setStocks(stocksResult.data);
setTotalCount(stocksResult.pagination.total);
}
if (statsResult.success && statsResult.data) {
setStockStats(statsResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockStatusList] loadData error:', error);
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터 로드 및 날짜 변경 시 재로드
useEffect(() => {
loadData();
}, [loadData]);
// 클라이언트 사이드 필터링
const filteredStocks = stocks.filter((stock) => {
// 검색 필터
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
stock.itemCode.toLowerCase().includes(searchLower) ||
stock.itemName.toLowerCase().includes(searchLower) ||
stock.stockNumber.toLowerCase().includes(searchLower);
if (!matchesSearch) return false;
}
// 상태 필터
const useStatusFilter = filterValues.useStatus as string;
if (useStatusFilter && useStatusFilter !== 'all') {
if (stock.useStatus !== useStatusFilter) return false;
}
return true;
});
// ===== 행 클릭 핸들러 =====
const handleRowClick = (item: StockItem) => {
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
};
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<StockItem>[] = [
{ header: '자재번호', key: 'stockNumber' },
{ header: '품목코드', key: 'itemCode' },
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || '-' },
{ header: '품목명', key: 'itemName' },
{ header: '규격', key: 'specification' },
{ header: '단위', key: 'unit' },
{ header: '재고량', key: 'calculatedQty' },
{ header: '안전재고', key: 'safetyStock' },
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
];
// ===== API 응답 매핑 함수 =====
const mapStockResponse = (result: unknown): StockItem[] => {
const data = result as { data?: { data?: Record<string, unknown>[] } };
const rawItems = data.data?.data ?? [];
return rawItems.map((item: Record<string, unknown>) => {
const stock = item.stock as Record<string, unknown> | null;
const hasStock = !!stock;
return {
id: String(item.id ?? ''),
stockNumber: hasStock ? (String(stock?.stock_number ?? stock?.id ?? item.id)) : String(item.id ?? ''),
itemCode: (item.code ?? '') as string,
itemName: (item.name ?? '') as string,
itemType: (item.item_type ?? 'RM') as ItemType,
specification: (item.specification ?? item.attributes ?? '') as string,
unit: (item.unit ?? 'EA') as string,
calculatedQty: hasStock ? (parseFloat(String(stock?.calculated_qty ?? stock?.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String(stock?.actual_qty ?? stock?.stock_qty)) || 0) : 0,
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
lotCount: hasStock ? (Number(stock?.lot_count) || 0) : 0,
lotDaysElapsed: hasStock ? (Number(stock?.days_elapsed) || 0) : 0,
status: hasStock ? (stock?.status as StockStatusType | null) : null,
useStatus: (item.is_active === false || item.status === 'inactive') ? 'inactive' : 'active',
location: hasStock ? ((stock?.location as string) || '-') : '-',
hasStock,
};
});
};
// ===== 통계 카드 =====
const stats = [
{
label: '전체 품목',
value: `${stockStats?.totalItems || 0}`,
icon: Package,
iconColor: 'text-gray-600',
},
{
label: '정상 재고',
value: `${stockStats?.normalCount || 0}`,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '재고 부족',
value: `${stockStats?.lowCount || 0}`,
icon: AlertCircle,
iconColor: 'text-red-600',
},
{
label: '안전재고 미달',
value: `${stockStats?.outCount || 0}`,
icon: AlertTriangle,
iconColor: 'text-orange-600',
},
];
// ===== 필터 설정 (전체/사용/미사용) =====
const filterConfig: FilterFieldConfig[] = [
{
key: 'useStatus',
label: '상태',
type: 'single',
options: [
{ value: 'all', label: '전체' },
{ value: 'active', label: '사용' },
{ value: 'inactive', label: '미사용' },
],
},
];
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'stockNumber', label: '자재번호', className: 'w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
{ key: 'specification', label: '규격', className: 'w-[100px]' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'calculatedQty', label: '재고량', className: 'w-[80px] text-center' },
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
];
// ===== 테이블 행 렌더링 =====
const renderTableRow = (
item: StockItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<StockItem>
) => {
return (
<TableRow
key={item.id}
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.stockNumber}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell>{ITEM_TYPE_LABELS[item.itemType] || '-'}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
<TableCell>{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">{item.calculatedQty}</TableCell>
<TableCell className="text-center">{item.safetyStock}</TableCell>
<TableCell className="text-center">
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
{USE_STATUS_LABELS[item.useStatus]}
</span>
</TableCell>
</TableRow>
);
};
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = (
item: StockItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<StockItem>
) => {
return (
<ListMobileCard
key={item.id}
id={item.id}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
onClick={() => handleRowClick(item)}
headerBadges={
<>
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
<Badge variant="outline" className="text-xs">{item.stockNumber}</Badge>
</>
}
title={item.itemName}
statusBadge={
<Badge
variant="outline"
className={`text-xs ${item.useStatus === 'inactive' ? 'text-gray-400' : ''}`}
>
{USE_STATUS_LABELS[item.useStatus]}
</Badge>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="품목코드" value={item.itemCode} />
<InfoField label="품목유형" value={ITEM_TYPE_LABELS[item.itemType] || '-'} />
<InfoField label="규격" value={item.specification || '-'} />
<InfoField label="단위" value={item.unit} />
<InfoField label="재고량" value={`${item.calculatedQty}`} />
<InfoField label="안전재고" value={`${item.safetyStock}`} />
</div>
}
actions={
handlers.isSelected && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
handleRowClick(item);
}}
>
<Eye className="w-4 h-4 mr-1" />
</Button>
</div>
)
}
/>
);
};
// ===== UniversalListPage Config (수주관리 패턴 - useMemo 없음) =====
const config: UniversalListConfig<StockItem> = {
title: '재고 목록',
description: '재고를 관리합니다',
icon: Package,
basePath: '/material/stock-status',
idField: 'id',
// 클라이언트 사이드 필터링 (수주관리 패턴)
actions: {
getList: async () => ({
success: true,
data: filteredStocks,
totalCount: filteredStocks.length,
}),
},
columns: tableColumns,
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '품목코드, 품목명 검색...',
// 검색 필터 함수
searchFilter: (stock, searchValue) => {
const searchLower = searchValue.toLowerCase();
return (
stock.itemCode.toLowerCase().includes(searchLower) ||
stock.itemName.toLowerCase().includes(searchLower) ||
stock.stockNumber.toLowerCase().includes(searchLower)
);
},
// 커스텀 필터 함수
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
const useStatusVal = fv.useStatus as string;
if (useStatusVal && useStatusVal !== 'all' && item.useStatus !== useStatusVal) {
return false;
}
return true;
});
},
// 날짜 범위 필터
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 필터 설정
filterConfig,
initialFilters: filterValues,
// 통계
computeStats: () => stats,
// 테이블 푸터
tableFooter: (
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableCell colSpan={12} className="py-3">
<span className="text-sm text-muted-foreground">
{filteredStocks.length}
</span>
</TableCell>
</TableRow>
),
// 엑셀 다운로드 설정
excelDownload: {
columns: excelColumns,
filename: '재고현황',
sheetName: '재고',
fetchAllUrl: '/api/proxy/stocks',
fetchAllParams: ({ searchValue, filters }) => {
const params: Record<string, string> = {};
if (filters?.useStatus && filters.useStatus !== 'all') {
params.use_status = filters.useStatus as string;
}
if (searchValue) {
params.search = searchValue;
}
params.start_date = startDate;
params.end_date = endDate;
return params;
},
mapResponse: mapStockResponse,
},
renderTableRow,
renderMobileCard,
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<UniversalListPage<StockItem>
config={config}
initialData={filteredStocks}
initialTotalCount={filteredStocks.length}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
onSearchChange={setSearchTerm}
/>
);
}