[신규 화면] - 설비허브 + 설비관리 + 설비점검 - 재고조정 + 재고이동 [버그 수정] - 창고 NULL status 누락 - 작업지시 sync detail fallback - InspectionModal API 경로 - 검사결과 DB 저장 - seq_no 비순차 대응 - 출고 재고 부족 검증 - 자동 창고 매칭 - 내 접수 목록 필터 [UI 개선] - 사이드바 카드형 - 자재투입 컴팩트 - 커스텀 모달 - 불필요 버튼 제거
253 lines
11 KiB
TypeScript
253 lines
11 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;
|
|
warehouse_type?: string;
|
|
}
|
|
|
|
interface StockItem {
|
|
id: string;
|
|
item_code: string;
|
|
item_name?: string;
|
|
warehouse_code: string;
|
|
location_code?: string;
|
|
current_qty: string;
|
|
unit?: string;
|
|
}
|
|
|
|
interface SelectedItem {
|
|
stock: StockItem;
|
|
adjustQty: string;
|
|
type: "confirm" | "adjust";
|
|
}
|
|
|
|
export function InventoryTransfer() {
|
|
const router = useRouter();
|
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
|
const [selectedWarehouse, setSelectedWarehouse] = useState("all");
|
|
const [stockItems, setStockItems] = useState<StockItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchKeyword, setSearchKeyword] = useState("");
|
|
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
|
|
|
|
const fetchWarehouses = useCallback(async () => {
|
|
try {
|
|
const res = await apiClient.get("/outbound/warehouses");
|
|
setWarehouses(res.data?.data || []);
|
|
} catch { /* */ }
|
|
}, []);
|
|
|
|
const fetchStock = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = { pageSize: "500" };
|
|
if (selectedWarehouse !== "all") {
|
|
params.filters = JSON.stringify({ warehouse_code: selectedWarehouse });
|
|
}
|
|
const res = await apiClient.get("/data/inventory_stock", { params });
|
|
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);
|
|
}
|
|
}, [selectedWarehouse]);
|
|
|
|
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 addItem = (stock: StockItem) => {
|
|
if (selectedItems.find((s) => s.stock.id === stock.id)) return;
|
|
setSelectedItems((prev) => [...prev, { stock, adjustQty: "", type: "confirm" }]);
|
|
};
|
|
|
|
const removeItem = (id: string) => {
|
|
setSelectedItems((prev) => prev.filter((s) => s.stock.id !== id));
|
|
};
|
|
|
|
const confirmCount = selectedItems.filter((s) => s.type === "confirm").length;
|
|
const adjustCount = selectedItems.filter((s) => s.type === "adjust").length;
|
|
|
|
return (
|
|
<PopShell title="재고조정" showBanner={false}>
|
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
|
{/* Header */}
|
|
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4 shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<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>
|
|
<h1 className="text-xl font-bold text-gray-900">📦 재고조정</h1>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main — 2단 레이아웃 */}
|
|
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
|
{/* 왼쪽: 제품 선택 */}
|
|
<div className="flex-1 flex flex-col border-r border-gray-200 bg-white">
|
|
<div className="px-4 pt-4 pb-2 border-b border-gray-100">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-sm font-bold text-gray-700">📦 제품 선택</h2>
|
|
<button className="px-3 py-1.5 text-xs rounded-lg border border-gray-200 text-gray-500">📋 이력</button>
|
|
</div>
|
|
|
|
{/* 창고 탭 */}
|
|
<div className="flex gap-2 mb-3 overflow-x-auto pb-1">
|
|
<button
|
|
onClick={() => setSelectedWarehouse("all")}
|
|
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
|
|
selectedWarehouse === "all" ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-600"
|
|
}`}
|
|
>
|
|
전체
|
|
</button>
|
|
{warehouses.map((wh) => (
|
|
<button
|
|
key={wh.warehouse_code}
|
|
onClick={() => setSelectedWarehouse(wh.warehouse_code)}
|
|
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
|
|
selectedWarehouse === wh.warehouse_code ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-600"
|
|
}`}
|
|
>
|
|
{wh.warehouse_name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
<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-amber-400"
|
|
/>
|
|
<button className="px-4 py-2.5 rounded-xl bg-amber-500 text-white text-sm font-bold">🔍</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 품목 리스트 */}
|
|
<div className="flex-1 overflow-y-auto px-4 py-2">
|
|
{loading ? (
|
|
<div className="flex justify-center py-16">
|
|
<div className="w-8 h-8 border-4 border-amber-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">
|
|
<span className="text-4xl mb-3">📦</span>
|
|
<p className="text-sm">해당 창고에 재고가 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{filtered.map((item) => (
|
|
<div key={item.id} className="flex items-center justify-between py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center text-sm">📦</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-gray-900">
|
|
{item.item_name || item.item_code}
|
|
{item.item_name && <span className="text-gray-400 font-normal"> ({item.item_code})</span>}
|
|
</p>
|
|
<p className="text-xs text-gray-400">{item.warehouse_code}{item.location_code ? ` · ${item.location_code}` : ""}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-right">
|
|
<p className="text-lg font-bold text-gray-900">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
|
|
<p className="text-[10px] text-gray-400">{item.location_code || item.warehouse_code}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => addItem(item)}
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-lg font-bold transition-all ${
|
|
selectedItems.find((s) => s.stock.id === item.id)
|
|
? "bg-gray-300"
|
|
: "bg-amber-500 active:bg-amber-600"
|
|
}`}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: 처리 결과 */}
|
|
<div className="w-full lg:w-[400px] bg-gray-50 flex flex-col">
|
|
<div className="px-4 pt-4 pb-2 border-b border-gray-200 bg-white flex items-center justify-between">
|
|
<h2 className="text-sm font-bold text-gray-700">📋 처리 결과</h2>
|
|
<span className="text-xs font-bold text-amber-600 bg-amber-50 px-2.5 py-1 rounded-full">
|
|
{selectedItems.length}건
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
{selectedItems.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
|
<span className="text-4xl mb-3">📋</span>
|
|
<p className="text-sm">제품을 스캔/선택하여 처리하세요</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{selectedItems.map((sel) => (
|
|
<div key={sel.stock.id} className="bg-white rounded-xl border border-gray-200 p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-sm font-bold text-gray-900">{sel.stock.item_name || sel.stock.item_code}</p>
|
|
<button onClick={() => removeItem(sel.stock.id)} className="text-xs text-red-500 font-semibold">삭제</button>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mb-2">현재 재고: {parseFloat(sel.stock.current_qty || "0").toLocaleString()}</p>
|
|
<input
|
|
type="number"
|
|
placeholder="조정 수량"
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between">
|
|
<div className="flex gap-3 text-xs">
|
|
<span className="text-blue-600 font-semibold">확인 {confirmCount}</span>
|
|
<span className="text-amber-600 font-semibold">조정 {adjustCount}</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setSelectedItems([])}
|
|
className="px-4 py-2.5 rounded-xl border border-gray-200 text-xs font-semibold text-gray-600"
|
|
>
|
|
전체삭제
|
|
</button>
|
|
<button className="px-4 py-2.5 rounded-xl bg-amber-500 text-white text-xs font-bold active:bg-amber-600">
|
|
일괄 확정
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopShell>
|
|
);
|
|
}
|