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