Files
vexplor/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx
SeongHyun Kim 9f00988110 feat: POP 전면 개선 — 신규 화면 5개 + 버그 수정 9건 + UI 개선
[신규 화면]
- 설비허브 + 설비관리 + 설비점검
- 재고조정 + 재고이동

[버그 수정]
- 창고 NULL status 누락
- 작업지시 sync detail fallback
- InspectionModal API 경로
- 검사결과 DB 저장
- seq_no 비순차 대응
- 출고 재고 부족 검증
- 자동 창고 매칭
- 내 접수 목록 필터

[UI 개선]
- 사이드바 카드형
- 자재투입 컴팩트
- 커스텀 모달
- 불필요 버튼 제거
2026-04-10 10:28:39 +09:00

290 lines
13 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { PopShell } from "../PopShell";
interface Warehouse {
id: string;
warehouse_code: string;
warehouse_name: string;
}
interface StockItem {
id: string;
item_code: string;
item_name?: string;
warehouse_code: string;
location_code?: string;
current_qty: string;
}
interface PendingItem {
stock: StockItem;
moveQty: number;
toWarehouse: string;
}
export function InventoryMove() {
const router = useRouter();
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [fromWarehouse, setFromWarehouse] = useState("");
const [toWarehouse, setToWarehouse] = useState("");
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [pendingItems, setPendingItems] = useState<PendingItem[]>([]);
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/outbound/warehouses");
setWarehouses(res.data?.data || []);
} catch { /* */ }
}, []);
const fetchStock = useCallback(async () => {
if (!fromWarehouse) { setStockItems([]); return; }
setLoading(true);
try {
const res = await apiClient.get("/data/inventory_stock", {
params: { pageSize: "500", filters: JSON.stringify({ warehouse_code: fromWarehouse }) },
});
const data = res.data?.data?.data ?? res.data?.data ?? [];
setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []);
} catch {
setStockItems([]);
} finally {
setLoading(false);
}
}, [fromWarehouse]);
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
useEffect(() => { fetchStock(); }, [fetchStock]);
const filtered = stockItems.filter((item) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw);
});
const addToPending = (stock: StockItem) => {
if (!toWarehouse) { alert("도착 창고를 먼저 선택하세요."); return; }
if (pendingItems.find((p) => p.stock.id === stock.id)) return;
const qty = parseFloat(stock.current_qty || "0");
setPendingItems((prev) => [...prev, { stock, moveQty: qty, toWarehouse }]);
};
const removePending = (id: string) => {
setPendingItems((prev) => prev.filter((p) => p.stock.id !== id));
};
const fromWh = warehouses.find((w) => w.warehouse_code === fromWarehouse);
const toWh = warehouses.find((w) => w.warehouse_code === toWarehouse);
return (
<PopShell title="재고이동" showBanner={false}>
<div className="flex flex-col h-screen bg-gray-100">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-3 shrink-0">
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div className="flex-1">
<h1 className="text-lg font-bold text-gray-900">📦 </h1>
<p className="text-xs text-gray-500"> </p>
</div>
</div>
{/* 좌우 분할 */}
<div className="flex-1 flex overflow-hidden">
{/* ===== 왼쪽: 출발 창고 + 품목 선택 ===== */}
<div className="flex-1 flex flex-col bg-white border-r-2 border-gray-200">
{/* 출발 창고 헤더 */}
<div className="px-4 py-3 border-b border-gray-100 bg-blue-50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-blue-800">📤 </span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-600 font-semibold">FROM</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{warehouses.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => { setFromWarehouse(wh.warehouse_code); setPendingItems([]); }}
className={`px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap transition-all ${
fromWarehouse === wh.warehouse_code
? "bg-blue-600 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200"
}`}
>
{wh.warehouse_name}
</button>
))}
</div>
</div>
{/* 검색 */}
{fromWarehouse && (
<div className="px-4 py-2 border-b border-gray-100">
<div className="flex gap-2">
<input
type="text"
placeholder="품목명 / 코드 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400"
/>
<button className="px-4 py-2.5 rounded-xl bg-blue-500 text-white text-sm font-bold">🔍</button>
</div>
</div>
)}
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{!fromWarehouse ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-4xl mb-3">📦</span>
<p className="text-base font-semibold"> </p>
</div>
) : loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2">
{filtered.map((item) => {
const isPending = pendingItems.some((p) => p.stock.id === item.id);
return (
<button
key={item.id}
onClick={() => addToPending(item)}
disabled={isPending}
className={`w-full text-left p-3.5 rounded-xl border transition-all active:scale-[0.98] ${
isPending
? "bg-green-50 border-green-300 opacity-60"
: "bg-white border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-base font-bold text-gray-900">{item.item_name || item.item_code}</p>
<p className="text-xs text-gray-400">{item.item_code} · {item.location_code || item.warehouse_code}</p>
</div>
<div className="text-right">
<p className="text-xl font-bold text-blue-600">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* ===== 오른쪽: 도착 창고 + 이동 대기열 ===== */}
<div className="flex-1 flex flex-col bg-gray-50">
{/* 도착 창고 헤더 */}
<div className="px-4 py-3 border-b border-gray-100 bg-green-50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-green-800">📥 </span>
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-600 font-semibold">TO</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{warehouses
.filter((wh) => wh.warehouse_code !== fromWarehouse)
.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => setToWarehouse(wh.warehouse_code)}
className={`px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap transition-all ${
toWarehouse === wh.warehouse_code
? "bg-green-600 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200"
}`}
>
{wh.warehouse_name}
</button>
))}
</div>
</div>
{/* 이동 방향 표시 */}
{fromWh && toWh && (
<div className="px-4 py-2 bg-white border-b border-gray-200 flex items-center justify-center gap-3">
<span className="text-sm font-bold text-blue-600">{fromWh.warehouse_name}</span>
<span className="text-lg"></span>
<span className="text-sm font-bold text-green-600">{toWh.warehouse_name}</span>
</div>
)}
{/* 이동 대기 목록 */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{pendingItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-4xl mb-3">📋</span>
<p className="text-base font-semibold"> </p>
<p className="text-sm mt-1"> </p>
</div>
) : (
<div className="space-y-2">
{pendingItems.map((p) => (
<div key={p.stock.id} className="bg-white rounded-xl border-l-4 border-l-blue-500 border border-gray-200 p-3.5">
<div className="flex items-center justify-between mb-1">
<p className="text-base font-bold text-gray-900">{p.stock.item_name || p.stock.item_code}</p>
<button
onClick={() => removePending(p.stock.id)}
className="px-3 py-1.5 rounded-lg border border-red-200 bg-red-50 text-red-600 text-sm font-bold active:bg-red-100"
>
</button>
</div>
<p className="text-xs text-gray-400 mb-2">{p.stock.item_code}</p>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
{p.moveQty.toLocaleString()} EA
</span>
<span className="text-xs text-gray-400">
{p.stock.warehouse_code} {p.toWarehouse}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 하단 확정 바 */}
<div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between shrink-0">
<div className="text-sm text-gray-500">
: <strong className="text-blue-600">{pendingItems.length}</strong>
</div>
<button
onClick={() => alert("재고 이동 API 준비 중입니다.")}
disabled={pendingItems.length === 0}
className={`px-6 py-3 rounded-xl text-base font-bold text-white active:scale-[0.98] transition-all ${
pendingItems.length > 0
? "bg-red-500 hover:bg-red-600"
: "bg-gray-300"
}`}
>
{pendingItems.length > 0 && (
<span className="ml-1 bg-white text-red-500 text-xs font-bold px-2 py-0.5 rounded-full">
{pendingItems.length}
</span>
)}
</button>
</div>
</div>
</div>
</div>
</PopShell>
);
}