자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
446 lines
15 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
} |