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

968 lines
45 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";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
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;
warehouse_name?: string;
location_code?: string;
location_name?: string;
floor?: string;
current_qty: string;
unit?: string;
spec?: string;
}
interface WarehouseLocation {
id: string;
location_code: string;
location_name: string;
floor?: string;
zone?: string;
row_num?: string;
level_num?: string;
warehouse_code: string;
warehouse_name?: string;
}
type AdjustReason = "실사차이" | "파손/훼손" | "유효기간" | "반품처리" | "위치불일치" | "기타";
interface ProcessedItem {
stock: StockItem;
type: "confirm" | "adjust";
actualQty?: number;
reason?: AdjustReason;
newWarehouse?: string;
newLocation?: string;
memo?: string;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
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 [processedItems, setProcessedItems] = useState<ProcessedItem[]>([]);
// 토스트 메시지
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);
};
// 임시저장 cart_items ID 추적 (X버튼 개별 취소용)
const [savedCartIds, setSavedCartIds] = useState<Record<string, string>>({});
// 모달 상태
const [checkModal, setCheckModal] = useState<StockItem | null>(null);
const [adjustModal, setAdjustModal] = useState<StockItem | null>(null);
const [adjustQty, setAdjustQty] = useState("");
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadValue, setNumpadValue] = useState("");
const [adjustReason, setAdjustReason] = useState<AdjustReason | "">("");
const [adjustMemo, setAdjustMemo] = useState("");
const [adjustNewWh, setAdjustNewWh] = useState("");
const [adjustNewLoc, setAdjustNewLoc] = useState("");
// 위치불일치 UI 상태
const [showBarcodeScan, setShowBarcodeScan] = useState(false);
const [showWhSelectModal, setShowWhSelectModal] = useState(false);
const [showLocSelectModal, setShowLocSelectModal] = useState(false);
const [locSelectWhCode, setLocSelectWhCode] = useState("");
const [locSelectWhName, setLocSelectWhName] = useState("");
const [locationList, setLocationList] = useState<WarehouseLocation[]>([]);
const [locationLoading, setLocationLoading] = useState(false);
const [selectedLocationDisplay, setSelectedLocationDisplay] = useState("");
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> = {};
if (selectedWarehouse !== "all") {
params.warehouse_code = selectedWarehouse;
}
const res = await apiClient.get("/pop/inventory/stock-detail", { params });
const data = res.data?.data ?? [];
setStockItems(Array.isArray(data) ? data : []);
} catch {
setStockItems([]);
} finally {
setLoading(false);
}
}, [selectedWarehouse]);
// 특정 창고의 위치 목록 조회
const fetchLocations = useCallback(async (warehouseCode: string) => {
setLocationLoading(true);
try {
const res = await apiClient.get("/pop/inventory/locations", {
params: { warehouse_code: warehouseCode },
});
setLocationList(res.data?.data || []);
} catch {
setLocationList([]);
} finally {
setLocationLoading(false);
}
}, []);
// QR 스캔 결과로 위치 조회
const handleLocationQrScan = useCallback(async (code: string) => {
try {
const res = await apiClient.get("/pop/inventory/location-lookup", {
params: { code },
});
if (res.data?.success && res.data.data) {
const loc = res.data.data as WarehouseLocation;
setAdjustNewWh(loc.warehouse_code);
setAdjustNewLoc(loc.location_code);
setSelectedLocationDisplay(
`${loc.warehouse_name || loc.warehouse_code} · ${loc.location_name || loc.location_code}`
);
} else {
showToast("해당 위치코드를 찾을 수 없습니다", "error");
}
} catch {
showToast("위치 조회 실패", "error");
}
setShowBarcodeScan(false);
}, []);
// 창고 선택 → 위치 목록 모달
const handleWhSelect = useCallback((wh: Warehouse) => {
setLocSelectWhCode(wh.warehouse_code);
setLocSelectWhName(wh.warehouse_name);
setShowWhSelectModal(false);
fetchLocations(wh.warehouse_code);
setShowLocSelectModal(true);
}, [fetchLocations]);
// 위치 선택 완료
const handleLocSelect = useCallback((loc: WarehouseLocation) => {
setAdjustNewWh(loc.warehouse_code);
setAdjustNewLoc(loc.location_code);
setSelectedLocationDisplay(
`${loc.warehouse_name || loc.warehouse_code} · ${loc.location_name || loc.location_code}`
);
setShowLocSelectModal(false);
}, []);
// 위치 없이 창고만 선택 (위치 데이터가 없는 경우)
const handleWhOnlySelect = useCallback(() => {
setAdjustNewWh(locSelectWhCode);
setAdjustNewLoc("");
setSelectedLocationDisplay(locSelectWhName || locSelectWhCode);
setShowLocSelectModal(false);
}, [locSelectWhCode, locSelectWhName]);
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
useEffect(() => { fetchStock(); }, [fetchStock]);
// 임시저장 (cart_items 테이블 활용)
const TEMP_KEY = "inventory-adjust";
const handleTempSave = async () => {
if (processedItems.length === 0) return;
try {
// 1. 기존 saved 건 → cancelled
await apiClient.post("/pop/inventory/temp-status", {
cart_type: TEMP_KEY,
from_status: "saved",
to_status: "cancelled",
});
// 2. 새 데이터 저장 (tasks 구조 + id + row_data + status)
const newIds: Record<string, string> = {};
const toCreate = processedItems.map((p) => {
const id = crypto.randomUUID();
newIds[p.stock.id] = id;
return {
id,
cart_type: TEMP_KEY,
status: "saved",
row_data: JSON.stringify({
stock_id: p.stock.id,
item_code: p.stock.item_code,
item_name: p.stock.item_name,
warehouse_code: p.stock.warehouse_code,
warehouse_name: p.stock.warehouse_name,
location_code: p.stock.location_code,
current_qty: p.stock.current_qty,
unit: p.stock.unit,
spec: p.stock.spec,
type: p.type,
actualQty: p.actualQty,
reason: p.reason,
newWarehouse: p.newWarehouse,
newLocation: p.newLocation,
memo: p.memo,
}),
};
});
await apiClient.post("/pop/execute-action", {
tasks: [{ id: "cart-save-1", type: "cart-save" }],
data: {},
cartChanges: { toCreate },
});
setSavedCartIds(newIds);
showToast(`${processedItems.length}건 임시저장 완료`);
} catch {
showToast("임시저장 실패", "error");
}
};
// 임시저장 불러오기 (status='saved'인 건만)
const loadTempSave = useCallback(async () => {
try {
const res = await apiClient.get("/pop/inventory/temp-load", {
params: { cart_type: TEMP_KEY },
});
const rows = res.data?.data || [];
if (rows.length === 0) return;
const loaded: ProcessedItem[] = [];
const idMap: Record<string, string> = {};
for (const row of rows) {
try {
const d = typeof row.row_data === "string" ? JSON.parse(row.row_data) : row.row_data;
loaded.push({
stock: {
id: d.stock_id,
item_code: d.item_code,
item_name: d.item_name,
warehouse_code: d.warehouse_code,
warehouse_name: d.warehouse_name,
location_code: d.location_code,
current_qty: d.current_qty,
unit: d.unit,
spec: d.spec,
},
type: d.type,
actualQty: d.actualQty,
reason: d.reason,
newWarehouse: d.newWarehouse,
newLocation: d.newLocation,
memo: d.memo,
});
idMap[d.stock_id] = row.id;
} catch { /* skip */ }
}
if (loaded.length > 0) {
setProcessedItems(loaded);
setSavedCartIds(idMap);
}
} catch { /* no temp data */ }
}, []);
useEffect(() => { loadTempSave(); }, [loadTempSave]);
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 handleItemClick = (stock: StockItem) => {
if (processedItems.find((p) => p.stock.id === stock.id)) return;
setCheckModal(stock);
};
// 확인(이상없음)
const handleConfirmOk = () => {
if (!checkModal) return;
setProcessedItems((prev) => [...prev, { stock: checkModal, type: "confirm" }]);
setCheckModal(null);
};
// 조정 필요 → 조정 모달 열기
const handleNeedAdjust = () => {
if (!checkModal) return;
setAdjustModal(checkModal);
setAdjustQty(checkModal.current_qty || "0");
setAdjustReason("");
setAdjustMemo("");
setAdjustNewWh("");
setAdjustNewLoc("");
setSelectedLocationDisplay("");
setCheckModal(null);
};
// 조정 등록
const handleAdjustSubmit = () => {
if (!adjustModal || !adjustReason) {
showToast("조정 사유를 선택해주세요.", "error");
return;
}
setProcessedItems((prev) => [
...prev,
{
stock: adjustModal,
type: "adjust",
actualQty: parseFloat(adjustQty) || 0,
reason: adjustReason as AdjustReason,
newWarehouse: adjustNewWh || undefined,
newLocation: adjustNewLoc || undefined,
memo: adjustMemo || undefined,
},
]);
setAdjustModal(null);
};
const removeProcessed = async (stockId: string) => {
// cart_items에 저장된 건이면 status='cancelled'로 변경
const cartId = savedCartIds[stockId];
if (cartId) {
try {
await apiClient.post("/pop/inventory/temp-status", {
cart_type: TEMP_KEY,
to_status: "cancelled",
ids: [cartId],
});
} catch { /* 실패해도 UI에서는 제거 */ }
setSavedCartIds((prev) => {
const next = { ...prev };
delete next[stockId];
return next;
});
}
setProcessedItems((prev) => prev.filter((p) => p.stock.id !== stockId));
};
const confirmCount = processedItems.filter((p) => p.type === "confirm").length;
const adjustCount = processedItems.filter((p) => p.type === "adjust").length;
const [submitting, setSubmitting] = useState(false);
// 초기화 (전체 saved → cancelled)
const handleReset = async () => {
// cart_items에 saved 건이 있으면 전부 cancelled
if (Object.keys(savedCartIds).length > 0) {
try {
await apiClient.post("/pop/inventory/temp-status", {
cart_type: TEMP_KEY,
from_status: "saved",
to_status: "cancelled",
});
} catch { /* 실패해도 UI는 초기화 */ }
}
setProcessedItems([]);
setSavedCartIds({});
};
// 일괄 확정
const handleBatchConfirm = async () => {
if (processedItems.length === 0) return;
setSubmitting(true);
try {
const res = await apiClient.post("/pop/inventory/adjust-batch", {
items: processedItems.map((p) => ({
stock_id: p.stock.id,
item_code: p.stock.item_code,
warehouse_code: p.stock.warehouse_code,
location_code: p.stock.location_code || "",
system_qty: parseFloat(p.stock.current_qty || "0"),
type: p.type,
actual_qty: p.actualQty,
reason: p.reason,
new_warehouse: p.newWarehouse,
new_location: p.newLocation,
memo: p.memo,
})),
});
if (res.data?.success) {
// 확정 성공 → cart_items status='confirmed'
if (Object.keys(savedCartIds).length > 0) {
try {
await apiClient.post("/pop/inventory/temp-status", {
cart_type: TEMP_KEY,
from_status: "saved",
to_status: "confirmed",
});
} catch { /* 상태 변경 실패해도 확정은 완료 */ }
}
showToast(res.data.message || "처리 완료");
setProcessedItems([]);
setSavedCartIds({});
fetchStock();
} 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 REASONS: AdjustReason[] = ["실사차이", "파손/훼손", "유효기간", "반품처리", "위치불일치", "기타"];
const historyButton = (
<button onClick={() => router.push("/pop/inventory/adjust-history")} className="px-4 py-2 rounded-xl bg-white/10 text-white text-sm font-bold active:bg-white/20">
</button>
);
return (
<PopShell title="재고조정" showBanner={false} fullBleed showBack headerRight={historyButton}>
<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="px-3 py-2 border-b border-gray-100 bg-amber-50">
<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-amber-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-amber-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-amber-400 bg-white"
/>
<button className="px-5 py-3.5 rounded-xl bg-amber-500 text-white text-lg font-bold active:bg-amber-600">
</button>
</div>
</div>
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex justify-center py-16">
<div className="w-10 h-10 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-5xl mb-3">📦</span>
<p className="text-lg font-semibold"> </p>
</div>
) : (
<div className="divide-y divide-gray-100">
{filtered.map((item) => {
const isProcessed = processedItems.some((p) => p.stock.id === item.id);
const processedInfo = processedItems.find((p) => p.stock.id === item.id);
return (
<button
key={item.id}
onClick={() => handleItemClick(item)}
disabled={isProcessed}
className={`w-full flex items-center justify-between px-4 py-3.5 text-left transition-colors ${
isProcessed
? processedInfo?.type === "confirm"
? "bg-green-50"
: "bg-amber-50"
: "bg-white active:bg-gray-50"
}`}
>
<div className="flex-1 min-w-0">
<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 mt-0.5">
{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>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<div className="text-right">
<p className="text-2xl font-extrabold text-gray-900">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
<p className="text-xs text-gray-400">{item.unit || "EA"}</p>
</div>
{isProcessed ? (
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
processedInfo?.type === "confirm" ? "bg-green-500" : "bg-amber-500"
}`}>
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
) : (
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center text-white text-2xl font-bold active:bg-amber-600">
+
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* ===== 오른쪽: 처리 결과 ===== */}
<div className="w-[45%] min-w-[380px] flex flex-col bg-gray-50">
<div className="flex-1 overflow-y-auto px-2 py-2">
{/* 헤더 — 스크롤 영역 안 */}
<div className="flex items-center justify-between px-2 pb-2 mb-1">
<h2 className="text-lg font-bold text-gray-900"> </h2>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-green-600 bg-green-50 px-3 py-1.5 rounded-lg"> {confirmCount}</span>
<span className="text-sm font-bold text-amber-600 bg-amber-50 px-3 py-1.5 rounded-lg"> {adjustCount}</span>
</div>
</div>
{processedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-5xl mb-3">📋</span>
<p className="text-lg font-semibold"> </p>
<p className="text-lg font-semibold"> </p>
</div>
) : (
<div className="space-y-2">
{processedItems.map((p) => {
const qty = parseFloat(p.stock.current_qty || "0");
const diff = p.type === "adjust" && p.actualQty != null ? p.actualQty - qty : 0;
return (
<div key={p.stock.id} className={`rounded-xl border-2 p-4 ${
p.type === "confirm"
? "bg-white border-green-300 border-l-4 border-l-green-500"
: "bg-amber-50 border-amber-300 border-l-4 border-l-amber-500"
}`}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="text-base font-bold text-gray-900 truncate">{p.stock.item_name || p.stock.item_code}</p>
<p className="text-sm text-gray-400">{p.stock.warehouse_name || p.stock.warehouse_code}{p.stock.location_code ? ` · ${p.stock.location_code}` : ""}</p>
</div>
<button
onClick={() => removeProcessed(p.stock.id)}
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"
>
</button>
</div>
{p.type === "confirm" ? (
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-green-600 bg-green-100 px-3 py-1 rounded-lg"></span>
<span className="text-base font-bold text-gray-700">{qty.toLocaleString()} {p.stock.unit || "EA"}</span>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-bold text-amber-700 bg-amber-100 px-3 py-1 rounded-lg">
{qty.toLocaleString()} {p.actualQty?.toLocaleString()} ({diff > 0 ? "+" : ""}{diff})
</span>
</div>
{p.reason && (
<span className="inline-block text-sm px-3 py-1 rounded-lg bg-gray-100 text-gray-600 font-semibold">{p.reason}</span>
)}
{p.memo && (
<p className="text-sm text-gray-400 truncate">{p.memo}</p>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 하단 바 */}
<div className="px-3 py-2.5 border-t-2 border-gray-200 bg-white flex items-center gap-2 shrink-0">
<button
onClick={handleReset}
className="px-4 py-3 rounded-xl border-2 border-gray-300 text-base font-bold text-gray-600 active:bg-gray-100"
>
</button>
<button
onClick={handleTempSave}
disabled={processedItems.length === 0 || (Object.keys(savedCartIds).length === processedItems.length && processedItems.every(p => savedCartIds[p.stock.id]))}
className={`px-4 py-3 rounded-xl border-2 text-base font-bold ${
processedItems.length === 0 || (Object.keys(savedCartIds).length === processedItems.length && processedItems.every(p => savedCartIds[p.stock.id]))
? "border-gray-300 text-gray-400"
: "border-blue-300 text-blue-600 active:bg-blue-50"
}`}
>
</button>
<button
disabled={processedItems.length === 0 || submitting}
onClick={handleBatchConfirm}
className={`flex-1 py-3.5 rounded-xl text-lg font-bold text-white transition-all active:scale-[0.98] ${
processedItems.length > 0 && !submitting
? "bg-red-500 active:bg-red-600 shadow-lg"
: "bg-gray-300"
}`}
>
{submitting ? "처리중..." : `일괄 확정 (${processedItems.length}건)`}
</button>
</div>
</div>
{/* ===== 재고 확인 모달 ===== */}
{checkModal && (
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center" onClick={() => setCheckModal(null)}>
<div className="absolute inset-0 bg-black/50" />
<div className="relative bg-white rounded-t-3xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md sm:mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 pt-5 pb-2">
<h3 className="text-xl font-bold text-gray-900"> </h3>
<button onClick={() => setCheckModal(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"></button>
</div>
<div className="px-5 pb-4">
<div className="bg-amber-50 rounded-2xl p-5">
<p className="text-xl font-bold text-gray-900">{checkModal.item_name || checkModal.item_code}</p>
<p className="text-base text-gray-500 mt-1">{checkModal.item_code}{checkModal.spec ? ` · ${checkModal.spec}` : ""}</p>
<p className="text-sm text-gray-400 mt-1">{checkModal.warehouse_name || checkModal.warehouse_code}{checkModal.location_code ? ` · ${checkModal.location_code}` : ""}</p>
<div className="mt-4 bg-white rounded-xl px-4 py-3 text-center">
<p className="text-sm text-gray-500"></p>
<p className="text-4xl font-extrabold text-gray-900 mt-1">
{parseFloat(checkModal.current_qty || "0").toLocaleString()}
<span className="text-lg text-gray-400 ml-1">{checkModal.unit || "EA"}</span>
</p>
</div>
</div>
</div>
<div className="px-5 pb-5 flex gap-3">
<button onClick={() => setCheckModal(null)} className="flex-1 py-4 rounded-xl border-2 border-gray-300 text-lg font-bold text-gray-500 active:bg-gray-100">
</button>
<button onClick={handleConfirmOk} className="flex-1 py-4 rounded-xl bg-green-500 text-lg font-bold text-white active:bg-green-600 shadow-md">
</button>
<button onClick={handleNeedAdjust} className="flex-1 py-4 rounded-xl bg-amber-500 text-lg font-bold text-white active:bg-amber-600 shadow-md">
</button>
</div>
</div>
</div>
)}
{/* ===== 재고 조정 입력 모달 ===== */}
{adjustModal && (
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center" onClick={() => setAdjustModal(null)}>
<div className="absolute inset-0 bg-black/50" />
<div className="relative bg-white rounded-t-3xl sm:rounded-2xl shadow-2xl w-full sm:max-w-lg sm:mx-4 max-h-[92vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 pt-5 pb-2 sticky top-0 bg-white z-10">
<h3 className="text-xl font-bold text-gray-900"> </h3>
<button onClick={() => setAdjustModal(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"></button>
</div>
<div className="px-5 pb-4 space-y-3">
{/* 품목 정보 — 1행 */}
<div className="bg-amber-50 rounded-xl px-4 py-3 flex items-center justify-between gap-3">
<p className="text-lg font-bold text-gray-900 truncate">{adjustModal.item_name || adjustModal.item_code}</p>
<p className="text-sm text-gray-400 shrink-0">{adjustModal.item_code}{adjustModal.spec ? ` · ${adjustModal.spec}` : ""} · {adjustModal.warehouse_name || adjustModal.warehouse_code}</p>
</div>
{/* 실제 수량 — 터치로 숫자 키패드 열기 */}
<div>
<label className="text-base font-bold text-gray-700 mb-1.5 block"> </label>
<button
type="button"
onClick={() => { setNumpadValue(adjustQty); setNumpadOpen(true); }}
className="w-full px-4 py-5 rounded-xl border-2 border-amber-400 bg-amber-50 text-center active:bg-amber-100"
>
<span className="text-7xl font-extrabold text-gray-900">{adjustQty || "0"}</span>
<span className="text-2xl text-gray-400 ml-2">{adjustModal.unit || "EA"}</span>
</button>
{(() => {
const d = (parseFloat(adjustQty) || 0) - parseFloat(adjustModal.current_qty || "0");
if (d === 0) return null;
return (
<p className={`text-center text-lg font-bold mt-1 ${d > 0 ? "text-blue-600" : "text-red-600"}`}>
: {d > 0 ? `+${d}` : d} (: {parseFloat(adjustModal.current_qty || "0").toLocaleString()})
</p>
);
})()}
</div>
{/* 조정 사유 */}
<div>
<label className="text-base font-bold text-gray-700 mb-1.5 block"> </label>
<div className="grid grid-cols-3 gap-1.5">
{REASONS.map((r) => (
<button
key={r}
onClick={() => setAdjustReason(r)}
className={`py-4 rounded-xl text-base font-bold transition-all active:scale-[0.97] ${
adjustReason === r
? "bg-amber-500 text-white shadow-md"
: "bg-gray-100 text-gray-600 border border-gray-200"
}`}
>
{r}
</button>
))}
</div>
</div>
{/* 위치불일치 → 실제 위치 (QR스캔 / 직접선택) */}
{adjustReason === "위치불일치" && (
<div className="bg-blue-50 rounded-2xl p-4 space-y-3">
<label className="text-base font-bold text-blue-800 block"> </label>
<p className="text-sm text-blue-600">: {adjustModal.warehouse_name || adjustModal.warehouse_code} · {adjustModal.location_code || "-"}</p>
{/* 2개 큰 버튼: QR스캔 + 직접선택 */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setShowBarcodeScan(true)}
className="flex flex-col items-center justify-center gap-2 py-5 rounded-xl bg-white border-2 border-blue-300 text-blue-700 font-bold text-lg active:bg-blue-100 transition-all"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v1.875m0 3.75v-3.75m0 0h1.875m1.875 0h-1.875m0 0h-1.875" />
</svg>
QR스캔
</button>
<button
type="button"
onClick={() => setShowWhSelectModal(true)}
className="flex flex-col items-center justify-center gap-2 py-5 rounded-xl bg-white border-2 border-blue-300 text-blue-700 font-bold text-lg active:bg-blue-100 transition-all"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
</svg>
</button>
</div>
{/* 선택 결과 표시 */}
{selectedLocationDisplay && (
<div className="flex items-center justify-between bg-white rounded-xl border-2 border-green-300 px-4 py-3">
<div className="flex items-center gap-2">
<svg className="w-6 h-6 text-green-500 shrink-0" 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>
<span className="text-base font-bold text-gray-900">{selectedLocationDisplay}</span>
</div>
<button
type="button"
onClick={() => {
setAdjustNewWh("");
setAdjustNewLoc("");
setSelectedLocationDisplay("");
}}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 text-sm active:bg-gray-200"
>
</button>
</div>
)}
</div>
)}
{/* 메모 */}
<div>
<label className="text-base font-bold text-gray-700 mb-1 block"> ()</label>
<input
type="text"
value={adjustMemo}
onChange={(e) => setAdjustMemo(e.target.value)}
placeholder="메모를 입력하세요"
className="w-full px-4 py-3.5 rounded-xl border-2 border-gray-200 text-base focus:outline-none focus:border-amber-400"
/>
</div>
</div>
{/* 버튼 — 하단 고정 */}
<div className="px-5 pb-5 flex gap-3 sticky bottom-0 bg-white pt-2">
<button onClick={() => setAdjustModal(null)} className="py-5 px-8 rounded-xl border-2 border-gray-300 text-lg font-bold text-gray-500 active:bg-gray-100">
</button>
<button onClick={handleAdjustSubmit} className="flex-1 py-5 rounded-xl bg-amber-500 text-xl font-bold text-white active:bg-amber-600 shadow-md">
</button>
</div>
</div>
</div>
)}
{/* ===== 숫자 키패드 모달 ===== */}
{numpadOpen && (
<div className="fixed inset-0 z-[200] flex items-end sm:items-center justify-center" onClick={() => setNumpadOpen(false)}>
<div className="absolute inset-0 bg-black/60" />
<div className="relative bg-white rounded-t-3xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm sm:mx-4" onClick={(e) => e.stopPropagation()}>
<div className="px-5 pt-5 pb-3 text-center">
<p className="text-sm text-gray-400"> </p>
<p className="text-4xl font-extrabold text-gray-900 mt-2 min-h-[48px]">{numpadValue || "0"}</p>
</div>
<div className="px-4 pb-3 grid grid-cols-4 gap-2">
{["7","8","9","←","4","5","6","C","1","2","3",".",].map((k) => (
<button
key={k}
onClick={() => {
if (k === "←") setNumpadValue((v) => v.slice(0, -1));
else if (k === "C") setNumpadValue("");
else if (k === ".") setNumpadValue((v) => v.includes(".") ? v : v + ".");
else setNumpadValue((v) => (v === "0" ? k : v + k));
}}
className={`py-5 rounded-xl text-xl font-bold active:scale-[0.95] ${
k === "←" || k === "C" ? "bg-gray-200 text-gray-600" : "bg-gray-100 text-gray-900"
}`}
>
{k}
</button>
))}
<button
onClick={() => setNumpadValue((v) => (v === "0" ? "0" : v + "0"))}
className="col-span-2 py-5 rounded-xl text-xl font-bold bg-gray-100 text-gray-900 active:scale-[0.95]"
>
0
</button>
<button
onClick={() => { setAdjustQty(numpadValue || "0"); setNumpadOpen(false); }}
className="col-span-2 py-5 rounded-xl text-xl font-bold bg-amber-500 text-white active:bg-amber-600 shadow-md"
>
</button>
</div>
</div>
</div>
)}
{/* ===== QR 스캔 모달 ===== */}
<BarcodeScanModal
open={showBarcodeScan}
onOpenChange={setShowBarcodeScan}
targetField="위치코드"
barcodeFormat="all"
autoSubmit
onScanSuccess={handleLocationQrScan}
/>
{/* ===== 창고 선택 모달 ===== */}
{showWhSelectModal && (
<div className="fixed inset-0 z-[150] flex items-end justify-center" onClick={() => setShowWhSelectModal(false)}>
<div className="absolute inset-0 bg-black/50" />
<div className="relative bg-white rounded-t-3xl shadow-2xl w-full max-h-[80vh] 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={() => setShowWhSelectModal(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"></button>
</div>
<div className="px-4 pb-5 overflow-y-auto max-h-[65vh]">
<div className="grid grid-cols-2 gap-3">
{warehouses.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => handleWhSelect(wh)}
className="flex flex-col items-center justify-center gap-1 py-5 px-3 rounded-xl bg-blue-50 border-2 border-blue-200 active:bg-blue-100 transition-all min-h-[72px]"
>
<svg className="w-7 h-7 text-blue-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
</svg>
<span className="text-lg font-bold text-gray-900">{wh.warehouse_name}</span>
<span className="text-sm text-gray-400">{wh.warehouse_code}</span>
</button>
))}
</div>
</div>
</div>
</div>
)}
{/* ===== 위치 선택 모달 ===== */}
{showLocSelectModal && (
<div className="fixed inset-0 z-[150] flex items-end justify-center" onClick={() => setShowLocSelectModal(false)}>
<div className="absolute inset-0 bg-black/50" />
<div className="relative bg-white rounded-t-3xl shadow-2xl w-full max-h-[80vh] 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">{locSelectWhName} - </h3>
<button onClick={() => setShowLocSelectModal(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"></button>
</div>
<div className="px-4 pb-5 overflow-y-auto max-h-[65vh]">
{locationLoading ? (
<div className="flex justify-center py-12">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : locationList.length === 0 ? (
<div className="flex flex-col items-center py-12 text-gray-400">
<p className="text-lg font-semibold mb-4"> </p>
<button
onClick={handleWhOnlySelect}
className="py-4 px-8 rounded-xl bg-blue-500 text-white text-lg font-bold active:bg-blue-600 shadow-md"
>
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{locationList.map((loc) => (
<button
key={loc.id}
onClick={() => handleLocSelect(loc)}
className="flex flex-col items-start gap-0.5 py-4 px-4 rounded-xl bg-blue-50 border-2 border-blue-200 active:bg-blue-100 transition-all text-left min-h-[64px]"
>
<span className="text-base font-bold text-gray-900 truncate w-full">{loc.location_name || loc.location_code}</span>
<span className="text-sm text-gray-400 truncate w-full">
{[loc.floor, loc.zone, loc.row_num ? `${loc.row_num}` : "", loc.level_num ? `${loc.level_num}` : ""].filter(Boolean).join(" · ") || loc.location_code}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* ===== 토스트 메시지 ===== */}
{toastMsg && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[200] animate-in fade-in slide-in-from-top-2">
<div className={`px-6 py-3 rounded-2xl shadow-xl text-base font-bold ${
toastMsg.type === "success"
? "bg-green-600 text-white"
: "bg-red-600 text-white"
}`}>
{toastMsg.text}
</div>
</div>
)}
</div>
</PopShell>
);
}