diff --git a/src/components/material/StockStatus/StockStatusDetail.tsx b/src/components/material/StockStatus/StockStatusDetail.tsx
index 0c7399b3..1a63cdf1 100644
--- a/src/components/material/StockStatus/StockStatusDetail.tsx
+++ b/src/components/material/StockStatus/StockStatusDetail.tsx
@@ -69,6 +69,7 @@ interface StockDetailData {
unit: string;
calculatedQty: number;
safetyStock: number;
+ maxStock: number;
wipStatus: 'active' | 'inactive';
useStatus: 'active' | 'inactive';
}
@@ -86,9 +87,11 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
// 폼 데이터 (수정 모드용) - wipStatus는 읽기 전용이므로 제외
const [formData, setFormData] = useState<{
safetyStock: number;
+ maxStock: number;
useStatus: 'active' | 'inactive';
}>({
safetyStock: 0,
+ maxStock: 0,
useStatus: 'active',
});
@@ -122,12 +125,14 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
unit: data.unit,
calculatedQty: data.currentStock, // 재고량
safetyStock: data.safetyStock,
+ maxStock: data.maxStock || 0,
wipStatus: 'active', // 재공품 상태 (기본값: 사용)
useStatus: data.status === null ? 'active' : 'active', // 기본값
};
setDetail(detailData);
setFormData({
safetyStock: detailData.safetyStock,
+ maxStock: detailData.maxStock,
useStatus: detailData.useStatus,
});
} else {
@@ -169,10 +174,24 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
}));
};
+ // 저장 시 검증
+ const validateForm = (): string | null => {
+ if (formData.maxStock > 0 && formData.safetyStock > formData.maxStock) {
+ return '최대재고는 안전재고 이상이어야 합니다.';
+ }
+ return null;
+ };
+
// 저장 핸들러
const handleSave = async () => {
if (!detail) return;
+ const validationError = validateForm();
+ if (validationError) {
+ toast.error(validationError);
+ return;
+ }
+
setIsSaving(true);
try {
const result = await updateStock(id, formData);
@@ -186,6 +205,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
? {
...prev,
safetyStock: formData.safetyStock,
+ maxStock: formData.maxStock,
useStatus: formData.useStatus,
}
: null
@@ -331,16 +351,17 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
{renderReadOnlyField('품목명', detail.itemName)}
- {/* Row 2: 규격, 단위, 재고량, 안전재고 */}
+ {/* Row 2: 규격, 단위, 재고량, 안전재고(최소) */}
{renderReadOnlyField('규격', detail.specification)}
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('재고량', detail.calculatedQty)}
- {renderReadOnlyField('안전재고', detail.safetyStock)}
+ {renderReadOnlyField('안전재고 (최소)', detail.safetyStock)}
- {/* Row 3: 재공품, 상태 */}
+ {/* Row 3: 최대재고, 재공품, 상태 */}
+ {renderReadOnlyField('최대재고', detail.maxStock > 0 ? detail.maxStock : '-')}
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
@@ -382,7 +403,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
{/* 안전재고 (수정 가능) */}
- {/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
+ {/* Row 3: 최대재고 (수정 가능), 재공품 (읽기 전용), 상태 (수정 가능) */}
+ {/* 최대재고 (수정 가능) */}
+
+
+ handleInputChange('maxStock', e.target.value)}
+ className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ min={0}
+ placeholder="0 = 미설정"
+ />
+
{/* 재공품 (읽기 전용) */}
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
{/* 상태 (수정 가능) */}
diff --git a/src/components/material/StockStatus/StockStatusList.tsx b/src/components/material/StockStatus/StockStatusList.tsx
index 8974a7ad..93f66d05 100644
--- a/src/components/material/StockStatus/StockStatusList.tsx
+++ b/src/components/material/StockStatus/StockStatusList.tsx
@@ -66,6 +66,9 @@ export function StockStatusList() {
useStatus: 'all',
});
+ // ===== 통계 카드 필터 상태 =====
+ const [stockStatusFilter, setStockStatusFilter] = useState<'all' | 'low' | 'out'>('all');
+
// 데이터 로드 함수
const loadData = useCallback(async () => {
try {
@@ -120,6 +123,13 @@ export function StockStatusList() {
if (stock.useStatus !== useStatusFilter) return false;
}
+ // 통계 카드 재고상태 필터
+ if (stockStatusFilter === 'low') {
+ if (stock.status !== 'low') return false;
+ } else if (stockStatusFilter === 'out') {
+ if (stock.status !== 'out') return false;
+ }
+
return true;
});
@@ -137,6 +147,7 @@ export function StockStatusList() {
{ header: '단위', key: 'unit' },
{ header: '재고량', key: 'calculatedQty' },
{ header: '안전재고', key: 'safetyStock' },
+ { header: '최대재고', key: 'maxStock' },
{ header: '재공품', key: 'wipQty' },
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
];
@@ -189,6 +200,8 @@ export function StockStatusList() {
value: `${stockStats?.totalItems || 0}`,
icon: Package,
iconColor: 'text-gray-600',
+ onClick: () => setStockStatusFilter('all'),
+ isActive: stockStatusFilter === 'all',
},
{
label: '정상 재고',
@@ -201,12 +214,18 @@ export function StockStatusList() {
value: `${stockStats?.lowCount || 0}`,
icon: AlertCircle,
iconColor: 'text-red-600',
+ onClick: () => setStockStatusFilter(stockStatusFilter === 'low' ? 'all' : 'low'),
+ isActive: stockStatusFilter === 'low',
+ tooltip: '현재 재고량이 안전재고(최소) 미만인 품목입니다.\n발주를 검토하여 재고를 보충해야 합니다.\n클릭하면 해당 품목만 필터링됩니다.',
},
{
label: '안전재고 미달',
value: `${stockStats?.outCount || 0}`,
icon: AlertTriangle,
iconColor: 'text-orange-600',
+ onClick: () => setStockStatusFilter(stockStatusFilter === 'out' ? 'all' : 'out'),
+ isActive: stockStatusFilter === 'out',
+ tooltip: '재고가 0인 품목입니다.\n긴급 발주가 필요할 수 있습니다.\n클릭하면 해당 품목만 필터링됩니다.',
},
];
@@ -247,13 +266,14 @@ export function StockStatusList() {
// ===== 테이블 컬럼 =====
const tableColumns = useMemo(() => [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
- { key: 'itemCode', label: '품목코드', className: 'min-w-[100px]', copyable: true },
+ { key: 'itemCode', label: '품목코드', className: 'w-[80px]', copyable: true },
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]', copyable: true },
{ key: 'specification', label: '규격', className: 'w-[100px]', copyable: true },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center', copyable: true },
{ key: 'calculatedQty', label: '재고량', className: 'w-[80px] text-center', copyable: true },
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center', copyable: true },
+ { key: 'maxStock', label: '최대재고', className: 'w-[80px] text-center', copyable: true },
{ key: 'wipQty', label: '재공품', className: 'w-[80px] text-center', copyable: true },
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
], []);
@@ -285,6 +305,7 @@ export function StockStatusList() {
{item.unit}
{item.calculatedQty}
{item.safetyStock}
+
{item.maxStock}
{item.wipQty}
@@ -331,6 +352,7 @@ export function StockStatusList() {
+
}
diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts
index 0afb7134..1a411750 100644
--- a/src/components/material/StockStatus/actions.ts
+++ b/src/components/material/StockStatus/actions.ts
@@ -137,6 +137,7 @@ function transformApiToListItem(data: ItemApiData): StockItem {
actualQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
+ maxStock: hasStock ? (parseFloat(String(stock.max_stock)) || 0) : 0,
wipQty: hasStock ? (parseFloat(String((stock as unknown as Record).wip_qty)) || 0) : 0,
lotCount: hasStock ? (stock.lot_count || 0) : 0,
lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0,
@@ -198,6 +199,7 @@ function transformApiToDetail(data: ItemApiData): StockDetail {
unit: data.unit || 'EA',
currentStock: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
+ maxStock: hasStock ? (parseFloat(String(stock.max_stock)) || 0) : 0,
location: hasStock ? (stock.location || '-') : '-',
lotCount: hasStock ? (stock.lot_count || 0) : 0,
lastReceiptDate: hasStock ? (stock.last_receipt_date || '-') : '-',
@@ -280,12 +282,16 @@ export async function getStockById(id: string): Promise<{ success: boolean; data
// ===== 재고 단건 수정 =====
export async function updateStock(
- id: string, data: { safetyStock: number; useStatus: 'active' | 'inactive' }
+ id: string, data: { safetyStock: number; maxStock: number; useStatus: 'active' | 'inactive' }
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/stocks/${id}`),
method: 'PUT',
- body: { safety_stock: data.safetyStock, is_active: data.useStatus === 'active' },
+ body: {
+ safety_stock: data.safetyStock,
+ max_stock: data.maxStock,
+ is_active: data.useStatus === 'active',
+ },
errorMessage: '재고 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
diff --git a/src/components/material/StockStatus/types.ts b/src/components/material/StockStatus/types.ts
index 930340de..0e35a0b5 100644
--- a/src/components/material/StockStatus/types.ts
+++ b/src/components/material/StockStatus/types.ts
@@ -33,13 +33,14 @@ export const ITEM_TYPE_STYLES: Partial> = {
};
// 재고 상태
-export type StockStatusType = 'normal' | 'low' | 'out';
+export type StockStatusType = 'normal' | 'low' | 'out' | 'over';
// 재고 상태 라벨
export const STOCK_STATUS_LABELS: Record = {
normal: '정상',
low: '부족',
out: '없음',
+ over: '초과',
};
// LOT 상태
@@ -63,6 +64,7 @@ export interface StockItem {
actualQty: number; // 실제 재고량 (Stock.actual_qty)
stockQty: number; // Stock.stock_qty (없으면 0)
safetyStock: number; // Stock.safety_stock (없으면 0)
+ maxStock: number; // Stock.max_stock (없으면 0, 적정재고 상한)
wipQty: number; // 재공품 수량 (Stock.wip_qty, 없으면 0)
lotCount: number; // Stock.lot_count (없으면 0)
lotDaysElapsed: number; // Stock.days_elapsed (없으면 0)
@@ -107,6 +109,7 @@ export interface StockDetail {
// 재고 현황 (Stock - 없으면 기본값)
currentStock: number; // Stock.stock_qty
safetyStock: number; // Stock.safety_stock
+ maxStock: number; // Stock.max_stock (적정재고 상한)
location: string; // Stock.location
lotCount: number; // Stock.lot_count
lastReceiptDate: string; // Stock.last_receipt_date
diff --git a/src/components/organisms/StatCards.tsx b/src/components/organisms/StatCards.tsx
index 2e8f7c58..8eede657 100644
--- a/src/components/organisms/StatCards.tsx
+++ b/src/components/organisms/StatCards.tsx
@@ -1,6 +1,7 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { LucideIcon } from "lucide-react";
interface StatCardData {
@@ -15,6 +16,7 @@ interface StatCardData {
};
onClick?: () => void;
isActive?: boolean;
+ tooltip?: string;
}
interface StatCardsProps {
@@ -31,51 +33,65 @@ export function StatCards({ stats }: StatCardsProps) {
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2';
return (
-
- {stats.map((stat, index) => {
- const Icon = stat.icon;
- const isClickable = !!stat.onClick;
+
+
+ {stats.map((stat, index) => {
+ const Icon = stat.icon;
+ const isClickable = !!stat.onClick;
- return (
-
-
-
-
-
- {stat.label}
- {stat.sublabel && (
- {stat.sublabel}
- )}
-
-
- {stat.value}
-
- {stat.trend && (
-
- {stat.trend.value}
+ const card = (
+
+
+
+
+
+ {stat.label}
+ {stat.sublabel && (
+ {stat.sublabel}
+ )}
+
+ {stat.value}
+
+ {stat.trend && (
+
+ {stat.trend.value}
+
+ )}
+
+ {Icon && (
+
)}
- {Icon && (
-
- )}
-
-
-
- );
- })}
-
+
+
+ );
+
+ if (stat.tooltip) {
+ return (
+
+ {card}
+
+ {stat.tooltip}
+
+
+ );
+ }
+
+ return
{card}
;
+ })}
+
+
);
}
\ No newline at end of file