feat: [재고] 적정재고 UI 추가, 통계 카드 필터링 및 툴팁
This commit is contained in:
@@ -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)}
|
||||
{/* 상태 (수정 가능) */}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user