[신규 화면] - 설비허브 + 설비관리 + 설비점검 - 재고조정 + 재고이동 [버그 수정] - 창고 NULL status 누락 - 작업지시 sync detail fallback - InspectionModal API 경로 - 검사결과 DB 저장 - seq_no 비순차 대응 - 출고 재고 부족 검증 - 자동 창고 매칭 - 내 접수 목록 필터 [UI 개선] - 사이드바 카드형 - 자재투입 컴팩트 - 커스텀 모달 - 불필요 버튼 제거
290 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|