feat: [재고] 적정재고 UI 추가, 통계 카드 필터링 및 툴팁

This commit is contained in:
김보곤
2026-03-21 08:00:05 +09:00
parent 02f6a2b5d7
commit 5db4806cb0
5 changed files with 134 additions and 51 deletions

View File

@@ -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)}
</div>
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
{/* Row 2: 규격, 단위, 재고량, 안전재고(최소) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('규격', detail.specification)}
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('재고량', detail.calculatedQty)}
{renderReadOnlyField('안전재고', detail.safetyStock)}
{renderReadOnlyField('안전재고 (최소)', detail.safetyStock)}
</div>
{/* Row 3: 재공품, 상태 */}
{/* Row 3: 최대재고, 재공품, 상태 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('최대재고', detail.maxStock > 0 ? detail.maxStock : '-')}
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
</div>
@@ -382,7 +403,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
{/* 안전재고 (수정 가능) */}
<div>
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
()
</Label>
<Input
id="safetyStock"
@@ -395,8 +416,23 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
</div>
</div>
{/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
{/* Row 3: 최대재고 (수정 가능), 재공품 (읽기 전용), 상태 (수정 가능) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 최대재고 (수정 가능) */}
<div>
<Label htmlFor="maxStock" className="text-sm text-muted-foreground">
</Label>
<Input
id="maxStock"
type="number"
value={formData.maxStock}
onChange={(e) => handleInputChange('maxStock', e.target.value)}
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
min={0}
placeholder="0 = 미설정"
/>
</div>
{/* 재공품 (읽기 전용) */}
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
{/* 상태 (수정 가능) */}

View File

@@ -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() {
<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">{item.maxStock}</TableCell>
<TableCell className="text-center">{item.wipQty}</TableCell>
<TableCell className="text-center">
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
@@ -331,6 +352,7 @@ export function StockStatusList() {
<InfoField label="단위" value={item.unit} />
<InfoField label="재고량" value={`${item.calculatedQty}`} />
<InfoField label="안전재고" value={`${item.safetyStock}`} />
<InfoField label="최대재고" value={`${item.maxStock}`} />
<InfoField label="재공품" value={`${item.wipQty}`} />
</div>
}

View File

@@ -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<string, unknown>).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 };

View File

@@ -33,13 +33,14 @@ export const ITEM_TYPE_STYLES: Partial<Record<ItemType, string>> = {
};
// 재고 상태
export type StockStatusType = 'normal' | 'low' | 'out';
export type StockStatusType = 'normal' | 'low' | 'out' | 'over';
// 재고 상태 라벨
export const STOCK_STATUS_LABELS: Record<StockStatusType, string> = {
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

View File

@@ -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 (
<div className={gridClass}>
{stats.map((stat, index) => {
const Icon = stat.icon;
const isClickable = !!stat.onClick;
<TooltipProvider delayDuration={300}>
<div className={gridClass}>
{stats.map((stat, index) => {
const Icon = stat.icon;
const isClickable = !!stat.onClick;
return (
<Card
key={index}
className={`flex-1 min-w-0 transition-colors ${
isClickable ? 'cursor-pointer hover:border-primary/50' : ''
} ${
stat.isActive ? 'border-primary bg-primary/5' : ''
} ${
count % 2 === 1 && index === count - 1 && count < 6 ? 'col-span-2 sm:col-span-1' : ''
}`}
onClick={stat.onClick}
>
<CardContent className="p-2 md:p-3">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
{stat.label}
{stat.sublabel && (
<span className="ml-2 normal-case tracking-normal">{stat.sublabel}</span>
)}
</p>
<p className="font-bold text-base md:text-lg truncate">
{stat.value}
</p>
{stat.trend && (
<p className={`text-[9px] md:text-[10px] mt-0.5 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
{stat.trend.value}
const card = (
<Card
className={`flex-1 min-w-0 transition-colors ${
isClickable ? 'cursor-pointer hover:border-primary/50' : ''
} ${
stat.isActive ? 'border-primary bg-primary/5' : ''
} ${
count % 2 === 1 && index === count - 1 && count < 6 ? 'col-span-2 sm:col-span-1' : ''
}`}
onClick={stat.onClick}
>
<CardContent className="p-2 md:p-3">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
{stat.label}
{stat.sublabel && (
<span className="ml-2 normal-case tracking-normal">{stat.sublabel}</span>
)}
</p>
<p className="font-bold text-base md:text-lg truncate">
{stat.value}
</p>
{stat.trend && (
<p className={`text-[9px] md:text-[10px] mt-0.5 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
{stat.trend.value}
</p>
)}
</div>
{Icon && (
<Icon
className={`w-6 h-6 md:w-8 md:h-8 opacity-15 flex-shrink-0 ${stat.iconColor || 'text-blue-600'}`}
/>
)}
</div>
{Icon && (
<Icon
className={`w-6 h-6 md:w-8 md:h-8 opacity-15 flex-shrink-0 ${stat.iconColor || 'text-blue-600'}`}
/>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
);
if (stat.tooltip) {
return (
<Tooltip key={index}>
<TooltipTrigger asChild>{card}</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[280px] text-sm whitespace-pre-line">
{stat.tooltip}
</TooltipContent>
</Tooltip>
);
}
return <div key={index}>{card}</div>;
})}
</div>
</TooltipProvider>
);
}