feat: [재고목록] 행 클릭 액션 메뉴 + 사용현황 모달 추가

This commit is contained in:
김보곤
2026-03-22 11:48:28 +09:00
parent 33eaacd0be
commit acfe0e907d
2 changed files with 185 additions and 11 deletions

View File

@@ -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>
</>
);
}

View File

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