Files
vexplor/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx
SeongHyun Kim 8c23f48996 feat: POP 재고관리 전면 구현 — 재고조정/재고이동/다중품목 공정
재고조정:
- fullBleed 좌우 분할, 숫자키패드 모달, 위치불일치 QR스캔+모달
- 임시저장 cart_items 상태관리 (saved/cancelled/confirmed)
- 조정이력 별도 페이지, DateRangePicker 통일
- popInventoryController 11개 API (adjust-batch, stock-detail, locations 등)

재고이동:
- 창고 탭: 탭 버튼 패턴 + flat 리스트 (아코디언 제거)
- 공정 탭: 공정명/설비 필터 모달 (작업지시번호 탭 제거)
- move-batch API: 창고→창고 + 공정→창고 (source_type 확장)
- 품목 이력 바텀시트 (transaction_type별 색상)

다중품목 공정실행:
- syncWorkInstructions LIMIT 1 제거 → detail 전체 순회
- batch_id 기반 품목별 공정 분리
- WorkOrderList/ProcessWork 품목 구분 표시

기타:
- PopShell fullBleed 모드 추가
- alert() → 토스트 메시지 교체
- MonitoringSettings import 수정
2026-04-10 17:17:23 +09:00

1147 lines
52 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";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Warehouse {
id: string;
warehouse_code: string;
warehouse_name: string;
}
interface StockItem {
id: string;
item_code: string;
item_name?: string;
item_number?: string;
warehouse_code: string;
warehouse_name?: string;
location_code?: string;
location_name?: string;
floor?: string;
current_qty: string;
unit?: string;
spec?: string;
}
interface ProcessV2Item {
id: string;
wo_id: string;
seq_no: string;
process_name: string;
equipment_code: string;
good_qty: string;
input_qty: string;
status: string;
target_warehouse_id: string | null;
target_location_code: string | null;
is_last_process: boolean;
is_unstored: boolean;
waiting_qty: number;
next_input_total: number;
item_code: string;
item_name: string;
work_instruction_no: string;
}
interface HistoryItem {
id: string;
item_code: string;
warehouse_code: string;
location_code?: string;
transaction_type: string;
transaction_date: string;
quantity: string;
balance_qty: string;
remark: string;
writer: string;
manager_name?: string;
created_date: string;
}
interface PendingMove {
sourceType: "warehouse" | "process";
// warehouse source
stock?: StockItem;
// process source
processItem?: ProcessV2Item;
// common
toWarehouse: string;
toWarehouseName: string;
moveQty: number;
itemCode: string;
itemName: string;
}
type TabType = "warehouse" | "process";
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InventoryMove() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<TabType>("warehouse");
// 창고 탭 상태
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState("all");
const [searchKeyword, setSearchKeyword] = useState("");
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [stockLoading, setStockLoading] = useState(true);
// 공정 탭 상태
const [processNames, setProcessNames] = useState<string[]>([]);
const [equipments, setEquipments] = useState<string[]>([]);
const [selectedProcessName, setSelectedProcessName] = useState("");
const [selectedEquipment, setSelectedEquipment] = useState("");
const [processItems, setProcessItems] = useState<ProcessV2Item[]>([]);
const [processLoading, setProcessLoading] = useState(false);
const [showProcessModal, setShowProcessModal] = useState(false);
const [showEquipmentModal, setShowEquipmentModal] = useState(false);
// 이동 대기열
const [pendingItems, setPendingItems] = useState<PendingMove[]>([]);
// 이력 바텀시트
const [historyTarget, setHistoryTarget] = useState<{ item_code: string; item_name: string } | null>(null);
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// 도착 창고 선택 모달 (warehouse source)
const [moveTarget, setMoveTarget] = useState<StockItem | null>(null);
// 도착 창고 선택 모달 (process source)
const [processMoveTarget, setProcessMoveTarget] = useState<ProcessV2Item | null>(null);
// 수량 입력 모달
const [qtyModalSource, setQtyModalSource] = useState<{
sourceType: "warehouse" | "process";
stock?: StockItem;
processItem?: ProcessV2Item;
maxQty: number;
itemCode: string;
itemName: string;
} | null>(null);
const [qtyModalToWh, setQtyModalToWh] = useState<Warehouse | null>(null);
const [qtyInput, setQtyInput] = useState("");
// 토스트
const [toastMsg, setToastMsg] = useState<{ text: string; type: "success" | "error" } | null>(null);
const showToast = (text: string, type: "success" | "error" = "success") => {
setToastMsg({ text, type });
setTimeout(() => setToastMsg(null), 2500);
};
const [submitting, setSubmitting] = useState(false);
/* ---- 데이터 조회 ---- */
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/outbound/warehouses");
setWarehouses(res.data?.data || []);
} catch { /* */ }
}, []);
const fetchStock = useCallback(async () => {
setStockLoading(true);
try {
const params: Record<string, string> = {};
if (selectedWarehouse !== "all") {
params.warehouse_code = selectedWarehouse;
}
if (searchKeyword) {
params.keyword = searchKeyword;
}
const res = await apiClient.get("/pop/inventory/stock-detail", { params });
const data = res.data?.data ?? [];
setStockItems(Array.isArray(data) ? data : []);
} catch {
setStockItems([]);
} finally {
setStockLoading(false);
}
}, [selectedWarehouse, searchKeyword]);
const fetchProcessStockV2 = useCallback(async (procName?: string, equipCode?: string) => {
setProcessLoading(true);
try {
const params: Record<string, string> = {};
if (procName) params.process_name = procName;
if (equipCode) params.equipment_code = equipCode;
const res = await apiClient.get("/pop/inventory/process-stock-v2", { params });
const data = res.data?.data;
if (data?.processNames) setProcessNames(data.processNames);
if (data?.equipments) setEquipments(data.equipments);
if (data?.processes) setProcessItems(data.processes);
else setProcessItems([]);
} catch {
setProcessItems([]);
} finally {
setProcessLoading(false);
}
}, []);
const fetchItemHistory = useCallback(async (itemCode: string, itemName: string) => {
setHistoryTarget({ item_code: itemCode, item_name: itemName });
setHistoryLoading(true);
try {
const res = await apiClient.get("/pop/inventory/item-history", {
params: { item_code: itemCode },
});
setHistoryItems(res.data?.data ?? []);
} catch {
setHistoryItems([]);
} finally {
setHistoryLoading(false);
}
}, []);
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
useEffect(() => { fetchStock(); }, [fetchStock]);
useEffect(() => {
if (activeTab === "process") fetchProcessStockV2(selectedProcessName || undefined, selectedEquipment || undefined);
}, [activeTab, selectedProcessName, selectedEquipment, fetchProcessStockV2]);
/* ---- 창고 탭 검색 필터 ---- */
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) ||
(item.item_number || "").toLowerCase().includes(kw)
);
});
/* ---- 이동 플로우 (창고) ---- */
const handleMoveStart = (stock: StockItem) => {
setMoveTarget(stock);
setProcessMoveTarget(null);
};
/* ---- 이동 플로우 (공정) ---- */
const handleProcessMoveStart = (proc: ProcessV2Item) => {
setProcessMoveTarget(proc);
setMoveTarget(null);
};
/* ---- 도착 창고 선택 ---- */
const handleSelectToWarehouse = (wh: Warehouse) => {
if (moveTarget) {
// 창고 소스
const maxQty = parseFloat(moveTarget.current_qty || "0");
setQtyModalSource({
sourceType: "warehouse",
stock: moveTarget,
maxQty,
itemCode: moveTarget.item_code,
itemName: moveTarget.item_name || moveTarget.item_code,
});
setQtyModalToWh(wh);
setQtyInput(String(maxQty));
setMoveTarget(null);
} else if (processMoveTarget) {
// 공정 소스
const goodQty = parseFloat(processMoveTarget.good_qty || "0");
setQtyModalSource({
sourceType: "process",
processItem: processMoveTarget,
maxQty: goodQty,
itemCode: processMoveTarget.item_code,
itemName: processMoveTarget.item_name || processMoveTarget.item_code,
});
setQtyModalToWh(wh);
setQtyInput(String(goodQty));
setProcessMoveTarget(null);
}
};
// 수량 입력 (숫자키패드)
const handleNumpadPress = (key: string) => {
if (key === "C") {
setQtyInput("");
} else if (key === "BS") {
setQtyInput((prev) => prev.slice(0, -1));
} else if (key === ".") {
if (!qtyInput.includes(".")) {
setQtyInput((prev) => prev + ".");
}
} else {
setQtyInput((prev) => prev + key);
}
};
// 대기열에 추가
const handleAddToQueue = () => {
if (!qtyModalSource || !qtyModalToWh) return;
const qty = parseFloat(qtyInput);
if (!qty || qty <= 0) {
showToast("수량을 입력하세요", "error");
return;
}
if (qty > qtyModalSource.maxQty) {
showToast(`최대 수량은 ${qtyModalSource.maxQty.toLocaleString()}입니다`, "error");
return;
}
// 중복 체크
if (qtyModalSource.sourceType === "warehouse" && qtyModalSource.stock) {
const dup = pendingItems.find(
(p) => p.sourceType === "warehouse" && p.stock?.id === qtyModalSource.stock?.id && p.toWarehouse === qtyModalToWh.warehouse_code
);
if (dup) {
showToast("이미 대기열에 있는 항목입니다", "error");
setQtyModalSource(null);
setQtyModalToWh(null);
return;
}
}
if (qtyModalSource.sourceType === "process" && qtyModalSource.processItem) {
const dup = pendingItems.find(
(p) => p.sourceType === "process" && p.processItem?.id === qtyModalSource.processItem?.id
);
if (dup) {
showToast("이미 대기열에 있는 항목입니다", "error");
setQtyModalSource(null);
setQtyModalToWh(null);
return;
}
}
setPendingItems((prev) => [
...prev,
{
sourceType: qtyModalSource.sourceType,
stock: qtyModalSource.stock,
processItem: qtyModalSource.processItem,
toWarehouse: qtyModalToWh.warehouse_code,
toWarehouseName: qtyModalToWh.warehouse_name,
moveQty: qty,
itemCode: qtyModalSource.itemCode,
itemName: qtyModalSource.itemName,
},
]);
setQtyModalSource(null);
setQtyModalToWh(null);
showToast("대기열에 추가됨");
};
// 대기열에서 제거
const removePending = (index: number) => {
setPendingItems((prev) => prev.filter((_, i) => i !== index));
};
/* ---- 이동 확정 ---- */
const handleConfirmMove = async () => {
if (pendingItems.length === 0) return;
setSubmitting(true);
try {
const res = await apiClient.post("/pop/inventory/move-batch", {
items: pendingItems.map((p) => {
if (p.sourceType === "process" && p.processItem) {
return {
source_type: "process",
work_order_process_id: p.processItem.id,
item_code: p.itemCode,
from_warehouse: "",
to_warehouse: p.toWarehouse,
to_location: "",
quantity: p.moveQty,
};
}
return {
source_type: "warehouse",
item_code: p.itemCode,
from_warehouse: p.stock?.warehouse_code || "",
from_location: p.stock?.location_code || "",
to_warehouse: p.toWarehouse,
to_location: "",
quantity: p.moveQty,
stock_id: p.stock?.id || "",
};
}),
});
if (res.data?.success) {
showToast(res.data.message || "이동 완료");
setPendingItems([]);
fetchStock();
if (activeTab === "process") fetchProcessStockV2(selectedProcessName || undefined, selectedEquipment || undefined);
} else {
showToast(res.data?.message || "이동 실패", "error");
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } }; message?: string };
showToast(e?.response?.data?.message || e?.message || "오류 발생", "error");
} finally {
setSubmitting(false);
}
};
/* ---- 이력 트랜잭션 타입 아이콘/색상 ---- */
const txTypeStyle = (type: string) => {
if (type?.includes("입고") || type?.includes("공정입고")) return { bg: "bg-blue-100", text: "text-blue-700", icon: "+" };
if (type?.includes("출고")) return { bg: "bg-green-100", text: "text-green-700", icon: "-" };
if (type?.includes("조정")) return { bg: "bg-amber-100", text: "text-amber-700", icon: "~" };
if (type?.includes("공정") || type?.includes("이동")) return { bg: "bg-purple-100", text: "text-purple-700", icon: "M" };
return { bg: "bg-gray-100", text: "text-gray-700", icon: "?" };
};
// 현재 활성 모달의 소스 창고 (도착 선택 시 필터용)
const activeMoveSourceWarehouse = moveTarget?.warehouse_code || "";
/* ------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------ */
return (
<PopShell title="재고이동" showBanner={false} fullBleed showBack>
<div className="flex overflow-hidden" style={{ height: "calc(100dvh - 56px)" }}>
{/* ===== 왼쪽: 재고 현황 ===== */}
<div className="w-[55%] flex flex-col bg-white border-r-2 border-gray-200">
{/* 탭: 창고 / 공정 */}
<div className="flex border-b border-gray-200 shrink-0">
<button
onClick={() => setActiveTab("warehouse")}
className={`flex-1 py-3.5 text-center text-base font-bold transition-colors ${
activeTab === "warehouse"
? "text-blue-600 border-b-3 border-blue-600 bg-blue-50"
: "text-gray-400"
}`}
>
</button>
<button
onClick={() => setActiveTab("process")}
className={`flex-1 py-3.5 text-center text-base font-bold transition-colors ${
activeTab === "process"
? "text-purple-600 border-b-3 border-purple-600 bg-purple-50"
: "text-gray-400"
}`}
>
</button>
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-y-auto flex flex-col">
{activeTab === "warehouse" ? (
/* ---- 창고 탭 ---- */
<>
{/* 창고 탭 버튼 + 검색 (InventoryTransfer 패턴) */}
<div className="px-3 py-2 border-b border-gray-100 bg-blue-50 shrink-0">
<div className="flex gap-1.5 overflow-x-auto pb-1.5">
<button
onClick={() => setSelectedWarehouse("all")}
className={`px-5 py-3 rounded-xl text-base font-bold whitespace-nowrap transition-all active:scale-[0.97] ${
selectedWarehouse === "all"
? "bg-blue-500 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200"
}`}
>
</button>
{warehouses.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => setSelectedWarehouse(wh.warehouse_code)}
className={`px-5 py-3 rounded-xl text-base font-bold whitespace-nowrap transition-all active:scale-[0.97] ${
selectedWarehouse === wh.warehouse_code
? "bg-blue-500 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200"
}`}
>
{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-3.5 rounded-xl border border-gray-200 text-lg focus:outline-none focus:border-blue-400 bg-white"
/>
<button
onClick={() => fetchStock()}
className="px-5 py-3.5 rounded-xl bg-blue-500 text-white text-lg font-bold active:bg-blue-600"
>
</button>
</div>
</div>
{/* 품목 리스트 (flat divide-y) */}
<div className="flex-1 overflow-y-auto">
{stockLoading ? (
<div className="flex justify-center py-16">
<div className="w-10 h-10 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-lg font-semibold"> </p>
</div>
) : (
<div className="divide-y divide-gray-100">
{filtered.map((item) => {
const qty = parseFloat(item.current_qty || "0");
const isPending = pendingItems.some(
(p) => p.sourceType === "warehouse" && p.stock?.id === item.id
);
return (
<div
key={item.id}
className={`flex items-center px-4 py-3 ${isPending ? "bg-blue-50" : "bg-white"}`}
>
{/* 품목 정보 - 터치하면 이력 */}
<button
onClick={() => fetchItemHistory(item.item_code, item.item_name || item.item_code)}
className="flex-1 min-w-0 text-left active:opacity-70"
>
<p className="text-lg font-bold text-gray-900 truncate">
{item.item_name || item.item_code} / {item.item_code}
</p>
<p className="text-sm text-gray-400">
{item.spec ? `${item.spec} / ` : ""}{item.warehouse_name || item.warehouse_code}{item.floor ? ` ${item.floor}` : ""}{item.location_name ? ` ${item.location_name}` : item.location_code ? ` ${item.location_code}` : ""}
</p>
</button>
{/* 수량 */}
<div className="text-right mx-3 shrink-0">
<p className="text-xl font-extrabold text-gray-900">{qty.toLocaleString()}</p>
<p className="text-xs text-gray-400">{item.unit || "EA"}</p>
</div>
{/* 이동 버튼 */}
<button
onClick={() => handleMoveStart(item)}
disabled={isPending || qty <= 0}
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl font-bold shrink-0 active:scale-95 transition-all ${
isPending || qty <= 0
? "bg-gray-300"
: "bg-blue-500 active:bg-blue-600"
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
);
})}
</div>
)}
</div>
</>
) : (
/* ---- 공정 탭 ---- */
<>
{/* 공정/설비 필터 */}
<div className="px-3 py-2 border-b border-gray-100 bg-purple-50 shrink-0">
<div className="flex gap-2">
{/* 공정 선택 버튼 */}
<button
onClick={() => setShowProcessModal(true)}
className="flex-1 flex items-center justify-between px-4 py-3.5 rounded-xl border-2 border-purple-200 bg-white active:bg-purple-50 transition-all"
>
<div className="text-left min-w-0">
<p className="text-xs text-gray-400"></p>
<p className="text-base font-bold text-gray-900 truncate">
{selectedProcessName || "전체"}
</p>
</div>
<svg className="w-5 h-5 text-gray-400 shrink-0 ml-2" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{/* 설비 선택 버튼 */}
<button
onClick={() => setShowEquipmentModal(true)}
className="flex-1 flex items-center justify-between px-4 py-3.5 rounded-xl border-2 border-purple-200 bg-white active:bg-purple-50 transition-all"
>
<div className="text-left min-w-0">
<p className="text-xs text-gray-400"></p>
<p className="text-base font-bold text-gray-900 truncate">
{selectedEquipment || "전체"}
</p>
</div>
<svg className="w-5 h-5 text-gray-400 shrink-0 ml-2" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</div>
</div>
{/* 품목 기준 리스트 */}
<div className="flex-1 overflow-y-auto">
{processLoading ? (
<div className="flex justify-center py-16">
<div className="w-10 h-10 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
) : processItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-lg font-semibold">
{processNames.length === 0 ? "양품이 있는 공정이 없습니다" : "해당 조건의 공정 데이터가 없습니다"}
</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{processItems.map((proc) => {
const goodQty = parseFloat(proc.good_qty || "0");
const isPending = pendingItems.some(
(p) => p.sourceType === "process" && p.processItem?.id === proc.id
);
const isStored = !!proc.target_warehouse_id;
return (
<div
key={proc.id}
className={`px-4 py-3 ${
isStored ? "bg-gray-50" : isPending ? "bg-purple-50" : proc.is_unstored ? "bg-red-50" : "bg-white"
}`}
>
<div className="flex items-center">
{/* 품목 정보 */}
<button
onClick={() => {
if (proc.item_code) fetchItemHistory(proc.item_code, proc.item_name || proc.item_code);
}}
className="flex-1 min-w-0 text-left active:opacity-70"
>
<p className="text-lg font-bold text-gray-900 truncate">
{proc.item_name || proc.item_code} / {proc.item_code}
</p>
<p className="text-sm text-gray-500 mt-0.5">
{proc.work_instruction_no}
{proc.seq_no ? ` · ${proc.seq_no}. ${proc.process_name}` : ` · ${proc.process_name}`}
{proc.equipment_code ? ` · ${proc.equipment_code}` : ""}
{proc.is_last_process && " · 최종"}
</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-gray-500">
: <span className="font-bold">{parseFloat(proc.input_qty || "0").toLocaleString()}</span>
</span>
<span className="text-sm text-gray-500">
: <span className="font-bold text-green-600">{goodQty.toLocaleString()}</span>
</span>
{proc.waiting_qty > 0 && !proc.is_last_process && (
<span className="text-sm text-purple-600 font-bold">
: {proc.waiting_qty.toLocaleString()}
</span>
)}
{proc.is_unstored && (
<span className="text-sm text-red-600 font-bold">
</span>
)}
{isStored && (
<span className="text-sm text-gray-400 font-semibold">
</span>
)}
</div>
</button>
{/* 양품 수량 */}
<div className="text-right mx-3 shrink-0">
<p className="text-xl font-extrabold text-gray-900">{goodQty.toLocaleString()}</p>
<p className="text-xs text-gray-400"></p>
</div>
{/* 이동 버튼 */}
{proc.is_unstored && !isPending && !isStored ? (
<button
onClick={() => handleProcessMoveStart(proc)}
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl font-bold shrink-0 active:scale-95 transition-all bg-purple-500 active:bg-purple-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</button>
) : isPending ? (
<span className="px-3 py-1.5 rounded-lg bg-purple-100 text-purple-600 text-sm font-bold shrink-0">
</span>
) : isStored ? (
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-gray-200 shrink-0">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
) : null}
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
</div>
</div>
{/* ===== 오른쪽: 이동 대기열 ===== */}
<div className="w-[45%] min-w-[380px] flex flex-col bg-gray-50">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-white shrink-0">
<h2 className="text-lg font-bold text-gray-900"> </h2>
<span className="text-sm font-bold text-blue-600 bg-blue-50 px-3 py-1.5 rounded-lg">
{pendingItems.length}
</span>
</div>
{/* 대기열 리스트 */}
<div className="flex-1 overflow-y-auto">
{pendingItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<svg className="w-16 h-16 mb-3 text-gray-300" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
<p className="text-lg font-semibold"> </p>
<p className="text-sm mt-1"> </p>
</div>
) : (
<div className="divide-y divide-gray-200">
{pendingItems.map((p, idx) => {
const isProcess = p.sourceType === "process";
return (
<div key={`pending-${idx}`} className={`px-4 py-3 ${isProcess ? "bg-purple-50" : "bg-white"}`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="text-base font-bold text-gray-900 truncate">
{p.itemName}
</p>
<div className="flex items-center gap-1.5 mt-1">
<span className={`text-sm font-semibold ${isProcess ? "text-purple-600" : "text-blue-600"}`}>
{isProcess ? `공정 (${p.processItem?.process_name || ""})` : (p.stock?.warehouse_name || p.stock?.warehouse_code || "")}
</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<span className="text-sm font-semibold text-green-600">
{p.toWarehouseName}
</span>
</div>
<p className="text-sm font-bold text-gray-700 mt-1">
{p.moveQty.toLocaleString()} EA
</p>
</div>
<button
onClick={() => removePending(idx)}
className="w-10 h-10 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-red-500 font-bold text-lg active:bg-red-100 shrink-0 ml-2"
>
X
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 하단 고정 버튼 */}
<div className="px-3 py-2.5 border-t-2 border-gray-200 bg-white shrink-0">
<button
disabled={pendingItems.length === 0 || submitting}
onClick={handleConfirmMove}
className={`w-full py-4 rounded-xl text-lg font-bold text-white transition-all active:scale-[0.98] ${
pendingItems.length > 0 && !submitting
? "bg-red-500 active:bg-red-600 shadow-lg"
: "bg-gray-300"
}`}
>
{submitting ? "처리중..." : `이동 확정 (${pendingItems.length}건)`}
</button>
</div>
</div>
{/* ===== 도착 창고 선택 모달 ===== */}
{(moveTarget || processMoveTarget) && (
<div
className="fixed inset-0 z-[100] flex items-end justify-center"
onClick={() => { setMoveTarget(null); setProcessMoveTarget(null); }}
>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative bg-white rounded-t-3xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<div>
<h3 className="text-xl font-bold text-gray-900"> </h3>
<p className="text-sm text-gray-400 mt-0.5">
{moveTarget
? `${moveTarget.item_name || moveTarget.item_code} (${parseFloat(moveTarget.current_qty || "0").toLocaleString()} ${moveTarget.unit || "EA"})`
: processMoveTarget
? `${processMoveTarget.item_name || processMoveTarget.item_code} (양품 ${parseFloat(processMoveTarget.good_qty || "0").toLocaleString()})`
: ""
}
</p>
</div>
<button
onClick={() => { setMoveTarget(null); setProcessMoveTarget(null); }}
className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center text-gray-500 text-lg active:bg-gray-200"
>
X
</button>
</div>
{/* 창고 카드 리스트 */}
<div className="px-5 pb-5 grid grid-cols-2 gap-3 max-h-[50vh] overflow-y-auto">
{warehouses
.filter((wh) => wh.warehouse_code !== activeMoveSourceWarehouse)
.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => handleSelectToWarehouse(wh)}
className="flex flex-col items-center justify-center p-4 rounded-2xl border-2 border-gray-200 bg-white active:border-blue-500 active:bg-blue-50 transition-all"
style={{ minHeight: 80 }}
>
<svg className="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z" />
</svg>
<span className="text-base font-bold text-gray-900">{wh.warehouse_name}</span>
</button>
))}
</div>
</div>
</div>
)}
{/* ===== 수량 입력 모달 (숫자키패드) ===== */}
{qtyModalSource && qtyModalToWh && (
<div
className="fixed inset-0 z-[100] flex items-end justify-center"
onClick={() => { setQtyModalSource(null); setQtyModalToWh(null); }}
>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative bg-white rounded-t-3xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="px-5 pt-5 pb-3">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold text-gray-900">
{qtyModalSource.sourceType === "process" ? "입고 수량 입력" : "이동 수량 입력"}
</h3>
<button
onClick={() => { setQtyModalSource(null); setQtyModalToWh(null); }}
className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center text-gray-500 text-lg active:bg-gray-200"
>
X
</button>
</div>
<p className="text-sm text-gray-400 mt-1">
{qtyModalSource.itemName}
</p>
<div className="flex items-center gap-2 mt-2">
<span className={`text-sm font-semibold px-2 py-1 rounded-lg ${
qtyModalSource.sourceType === "process"
? "text-purple-600 bg-purple-50"
: "text-blue-600 bg-blue-50"
}`}>
{qtyModalSource.sourceType === "process"
? `공정`
: (qtyModalSource.stock?.warehouse_name || qtyModalSource.stock?.warehouse_code || "")
}
</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<span className="text-sm font-semibold text-green-600 bg-green-50 px-2 py-1 rounded-lg">
{qtyModalToWh.warehouse_name}
</span>
</div>
</div>
{/* 수량 표시 */}
<div className="px-5 py-3">
<div className="bg-gray-100 rounded-2xl px-5 py-4 text-center">
<p className="text-xs text-gray-400 mb-1">
: {qtyModalSource.maxQty.toLocaleString()} EA
</p>
<p className="text-4xl font-extrabold text-gray-900">
{qtyInput || "0"}
</p>
</div>
</div>
{/* 숫자 키패드 */}
<div className="px-5 pb-3 grid grid-cols-3 gap-2">
{["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "BS"].map((key) => (
<button
key={key}
onClick={() => handleNumpadPress(key)}
className="py-4 rounded-xl bg-gray-100 text-xl font-bold text-gray-900 active:bg-gray-200 transition-colors"
style={{ minHeight: 56 }}
>
{key === "BS" ? (
<svg className="w-6 h-6 mx-auto text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75L14.25 12m0 0l2.25 2.25M14.25 12l2.25-2.25M14.25 12L12 14.25m-2.58 4.92l-6.375-6.375a1.125 1.125 0 010-1.59L9.42 4.83c.211-.211.498-.33.796-.33H19.5a2.25 2.25 0 012.25 2.25v10.5a2.25 2.25 0 01-2.25 2.25h-9.284c-.298 0-.585-.119-.796-.33z" />
</svg>
) : key}
</button>
))}
</div>
{/* 하단 버튼 */}
<div className="px-5 pb-5 flex gap-3">
<button
onClick={() => handleNumpadPress("C")}
className="px-6 py-4 rounded-xl border-2 border-gray-300 text-lg font-bold text-gray-500 active:bg-gray-100"
>
</button>
<button
onClick={() => {
setQtyInput(String(qtyModalSource.maxQty));
}}
className="px-6 py-4 rounded-xl border-2 border-blue-300 text-lg font-bold text-blue-600 active:bg-blue-50"
>
</button>
<button
onClick={handleAddToQueue}
className="flex-1 py-4 rounded-xl bg-blue-500 text-lg font-bold text-white active:bg-blue-600 shadow-md"
>
</button>
</div>
</div>
</div>
)}
{/* ===== 이력 바텀시트 ===== */}
{historyTarget && (
<div
className="fixed inset-0 z-[100] flex items-end justify-center"
onClick={() => setHistoryTarget(null)}
>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative bg-white rounded-t-3xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
style={{ maxHeight: "70vh" }}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100 shrink-0">
<div>
<h3 className="text-xl font-bold text-gray-900"> </h3>
<p className="text-sm text-gray-400 mt-0.5">
{historyTarget.item_name} ({historyTarget.item_code})
</p>
</div>
<button
onClick={() => setHistoryTarget(null)}
className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center text-gray-500 text-lg active:bg-gray-200"
>
X
</button>
</div>
{/* 이력 리스트 */}
<div className="overflow-y-auto" style={{ maxHeight: "calc(70vh - 80px)" }}>
{historyLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : historyItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<p className="text-base font-semibold"> </p>
</div>
) : (
<div className="divide-y divide-gray-100">
{historyItems.map((h) => {
const style = txTypeStyle(h.transaction_type);
const qty = parseFloat(h.quantity || "0");
return (
<div key={h.id} className="px-5 py-3 flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl ${style.bg} flex items-center justify-center shrink-0`}>
<span className={`text-lg font-bold ${style.text}`}>{style.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm font-bold ${style.text} ${style.bg} px-2 py-0.5 rounded`}>
{h.transaction_type || "-"}
</span>
<span className="text-xs text-gray-400">
{h.warehouse_code}{h.location_code ? ` / ${h.location_code}` : ""}
</span>
</div>
<p className="text-xs text-gray-400 mt-0.5">
{h.transaction_date ? new Date(h.transaction_date).toLocaleDateString("ko-KR") : ""}
{h.manager_name ? ` / ${h.manager_name}` : ""}
</p>
</div>
<div className="text-right shrink-0">
<p className={`text-lg font-bold ${qty >= 0 ? "text-blue-600" : "text-red-600"}`}>
{qty >= 0 ? "+" : ""}{qty.toLocaleString()}
</p>
{h.balance_qty && (
<p className="text-xs text-gray-400">
{parseFloat(h.balance_qty || "0").toLocaleString()}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}
</div>
{/* ===== 공정 선택 바텀시트 ===== */}
{showProcessModal && (
<div
className="fixed inset-0 z-[100] flex items-end justify-center"
onClick={() => setShowProcessModal(false)}
>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative bg-white rounded-t-3xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h3 className="text-xl font-bold text-gray-900"> </h3>
<button
onClick={() => setShowProcessModal(false)}
className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center text-gray-500 text-lg active:bg-gray-200"
>
X
</button>
</div>
<div className="px-5 pb-5 max-h-[50vh] overflow-y-auto">
<button
onClick={() => { setSelectedProcessName(""); setShowProcessModal(false); }}
className={`w-full flex items-center justify-between p-4 rounded-2xl border-2 mb-2 transition-all active:scale-[0.98] ${
!selectedProcessName ? "border-purple-500 bg-purple-50" : "border-gray-200 bg-white"
}`}
style={{ minHeight: 56 }}
>
<span className="text-lg font-bold text-gray-900"></span>
{!selectedProcessName && (
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
{processNames.map((name) => (
<button
key={name}
onClick={() => { setSelectedProcessName(name); setShowProcessModal(false); }}
className={`w-full flex items-center justify-between p-4 rounded-2xl border-2 mb-2 transition-all active:scale-[0.98] ${
selectedProcessName === name ? "border-purple-500 bg-purple-50" : "border-gray-200 bg-white"
}`}
style={{ minHeight: 56 }}
>
<span className="text-lg font-bold text-gray-900">{name}</span>
{selectedProcessName === name && (
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
))}
</div>
</div>
</div>
)}
{/* ===== 설비 선택 바텀시트 ===== */}
{showEquipmentModal && (
<div
className="fixed inset-0 z-[100] flex items-end justify-center"
onClick={() => setShowEquipmentModal(false)}
>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative bg-white rounded-t-3xl shadow-2xl w-full max-w-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h3 className="text-xl font-bold text-gray-900"> </h3>
<button
onClick={() => setShowEquipmentModal(false)}
className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center text-gray-500 text-lg active:bg-gray-200"
>
X
</button>
</div>
<div className="px-5 pb-5 max-h-[50vh] overflow-y-auto">
<button
onClick={() => { setSelectedEquipment(""); setShowEquipmentModal(false); }}
className={`w-full flex items-center justify-between p-4 rounded-2xl border-2 mb-2 transition-all active:scale-[0.98] ${
!selectedEquipment ? "border-purple-500 bg-purple-50" : "border-gray-200 bg-white"
}`}
style={{ minHeight: 56 }}
>
<span className="text-lg font-bold text-gray-900"></span>
{!selectedEquipment && (
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
{equipments.map((eq) => (
<button
key={eq}
onClick={() => { setSelectedEquipment(eq); setShowEquipmentModal(false); }}
className={`w-full flex items-center justify-between p-4 rounded-2xl border-2 mb-2 transition-all active:scale-[0.98] ${
selectedEquipment === eq ? "border-purple-500 bg-purple-50" : "border-gray-200 bg-white"
}`}
style={{ minHeight: 56 }}
>
<span className="text-lg font-bold text-gray-900">{eq}</span>
{selectedEquipment === eq && (
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
))}
{equipments.length === 0 && (
<div className="py-8 text-center text-gray-400">
<p className="text-base font-semibold"> </p>
</div>
)}
</div>
</div>
</div>
)}
{/* 토스트 메시지 */}
{toastMsg && (
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[200] px-6 py-3 rounded-xl shadow-lg text-base font-bold text-white animate-fade-in"
style={{ backgroundColor: toastMsg.type === "success" ? "#22c55e" : "#ef4444" }}
>
{toastMsg.text}
</div>
)}
</PopShell>
);
}