Files
vexplor/frontend/components/pop/hardcoded/inventory/InventoryTransfer.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

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