재고조정: - 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 수정
1147 lines
52 KiB
TypeScript
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>
|
|
);
|
|
}
|