From 5db4806cb00586d5cc474f36d302f55907132a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 21 Mar 2026 08:00:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=9E=AC=EA=B3=A0]=20=EC=A0=81?= =?UTF-8?q?=EC=A0=95=EC=9E=AC=EA=B3=A0=20UI=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=ED=88=B4=ED=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockStatus/StockStatusDetail.tsx | 46 +++++++- .../material/StockStatus/StockStatusList.tsx | 24 ++++- .../material/StockStatus/actions.ts | 10 +- src/components/material/StockStatus/types.ts | 5 +- src/components/organisms/StatCards.tsx | 100 ++++++++++-------- 5 files changed, 134 insertions(+), 51 deletions(-) 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