feat: [재고목록] 행 클릭 액션 메뉴 + 사용현황 모달 추가
This commit is contained in:
@@ -32,8 +32,15 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getStocks, getStockStats } from './actions';
|
||||
import { getStocks, getStockStats, getStockTransactions, type StockUsageData } from './actions';
|
||||
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, History, ExternalLink } from 'lucide-react';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getLocalDateString } from '@/lib/utils/date';
|
||||
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
|
||||
@@ -147,9 +154,23 @@ export function StockStatusList() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
// ===== 행 클릭 → 액션 메뉴 =====
|
||||
const [actionMenuItemId, setActionMenuItemId] = useState<string | null>(null);
|
||||
const [usageModalItem, setUsageModalItem] = useState<StockItem | null>(null);
|
||||
const [usageData, setUsageData] = useState<StockUsageData | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
|
||||
const handleRowClick = (item: StockItem) => {
|
||||
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
|
||||
setActionMenuItemId(prev => prev === item.id ? null : item.id);
|
||||
};
|
||||
|
||||
const handleViewUsage = async (item: StockItem) => {
|
||||
setActionMenuItemId(null);
|
||||
setUsageModalItem(item);
|
||||
setUsageLoading(true);
|
||||
const result = await getStockTransactions(item.id);
|
||||
if (result.success && result.data) setUsageData(result.data);
|
||||
setUsageLoading(false);
|
||||
};
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
@@ -302,9 +323,10 @@ export function StockStatusList() {
|
||||
handlers: SelectionHandlers & RowClickHandlers<StockItem>
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''} ${actionMenuItemId === item.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -329,6 +351,31 @@ export function StockStatusList() {
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{actionMenuItemId === item.id && (
|
||||
<TableRow key={`action-${item.id}`}>
|
||||
<TableCell colSpan={12} className="p-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-y border-blue-100">
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
className="h-8 text-xs gap-1.5 border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
onClick={(e) => { e.stopPropagation(); handleViewUsage(item); }}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
사용현황 보기
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={(e) => { e.stopPropagation(); setActionMenuItemId(null); router.push(`/ko/material/stock-status/${item.id}?mode=view`); }}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
상세보기
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -515,12 +562,79 @@ export function StockStatusList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
<>
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
|
||||
{/* 사용현황 모달 */}
|
||||
<Dialog open={!!usageModalItem} onOpenChange={(open) => { if (!open) { setUsageModalItem(null); setUsageData(null); } }}>
|
||||
<DialogContent className="!max-w-2xl max-h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-blue-600" />
|
||||
사용현황
|
||||
</DialogTitle>
|
||||
{usageModalItem && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-sm font-medium text-gray-900">{usageModalItem.itemCode}</span>
|
||||
<span className="text-sm text-gray-500">{usageModalItem.itemName}</span>
|
||||
{usageData && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
|
||||
현재 재고: {usageData.availableQty} {usageModalItem.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||
{usageLoading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
||||
</div>
|
||||
) : usageData?.transactions.length ? (
|
||||
<div className="space-y-2">
|
||||
{usageData.transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex items-start gap-3 p-3 rounded-lg border bg-white hover:bg-gray-50">
|
||||
<div className={`shrink-0 mt-0.5 w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
tx.qty < 0 ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
}`}>
|
||||
{tx.qty < 0 ? '출' : '입'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{tx.typeLabel}</span>
|
||||
<span className={`text-sm font-bold ${tx.qty < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{tx.qty > 0 ? '+' : ''}{tx.qty} {usageModalItem?.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-gray-500">
|
||||
{tx.referenceNo && <span className="font-medium text-blue-600">{tx.referenceNo}</span>}
|
||||
{tx.lotNo && <span>LOT: {tx.lotNo}</span>}
|
||||
<span>{tx.createdAt}</span>
|
||||
</div>
|
||||
{tx.remark && <p className="text-xs text-gray-400 mt-0.5">{tx.remark}</p>}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-xs text-gray-400">잔량</div>
|
||||
<div className="text-sm font-medium">{tx.balanceQty}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-40 text-gray-400">
|
||||
사용 이력이 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
StockItem,
|
||||
StockDetail,
|
||||
@@ -345,3 +346,62 @@ export async function updateStockAudit(updates: { id: string; actualQty: number
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 사용현황(거래이력) 조회 =====
|
||||
export interface StockTransaction {
|
||||
id: number;
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
qty: number;
|
||||
balanceQty: number;
|
||||
referenceType: string;
|
||||
referenceId: number;
|
||||
referenceNo: string | null;
|
||||
lotNo: string;
|
||||
reason: string | null;
|
||||
remark: string | null;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StockUsageData {
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
currentQty: number;
|
||||
availableQty: number;
|
||||
transactions: StockTransaction[];
|
||||
}
|
||||
|
||||
export async function getStockTransactions(
|
||||
itemId: string
|
||||
): Promise<{ success: boolean; data?: StockUsageData; error?: string }> {
|
||||
const result = await executeServerAction<{
|
||||
item_code: string; item_name: string; current_qty: number; available_qty: number;
|
||||
transactions: Array<{
|
||||
id: number; type: string; type_label: string; qty: number; balance_qty: number;
|
||||
reference_type: string; reference_id: number; reference_no: string | null;
|
||||
lot_no: string; reason: string | null; remark: string | null;
|
||||
item_code: string; item_name: string; created_at: string;
|
||||
}>;
|
||||
}>({
|
||||
url: buildApiUrl(`/api/v1/stocks/${itemId}/transactions`),
|
||||
errorMessage: '사용현황 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
itemCode: result.data.item_code,
|
||||
itemName: result.data.item_name,
|
||||
currentQty: result.data.current_qty,
|
||||
availableQty: result.data.available_qty,
|
||||
transactions: result.data.transactions.map((t: { id: number; type: string; type_label: string; qty: number; balance_qty: number; reference_type: string; reference_id: number; reference_no: string | null; lot_no: string; reason: string | null; remark: string | null; item_code: string; item_name: string; created_at: string }) => ({
|
||||
id: t.id, type: t.type, typeLabel: t.type_label, qty: t.qty, balanceQty: t.balance_qty,
|
||||
referenceType: t.reference_type, referenceId: t.reference_id, referenceNo: t.reference_no,
|
||||
lotNo: t.lot_no, reason: t.reason, remark: t.remark,
|
||||
itemCode: t.item_code, itemName: t.item_name, createdAt: t.created_at,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user