Some checks failed
Build and Push Images / build-and-push (push) Failing after 49s
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단 - 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선 - 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거 - 검사관리+입출고관리: 신규 화면 (quality, inventory) - 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
1214 lines
40 KiB
TypeScript
1214 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { type CartItemWithId, useCartSync } from "../common/useCartSync";
|
|
import {
|
|
InspectionModal,
|
|
type InspectionResult,
|
|
} from "../inbound/InspectionModal";
|
|
import { NumberPadModal, type PackageEntry } from "../inbound/NumberPadModal";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface Warehouse {
|
|
warehouse_code: string;
|
|
warehouse_name: string;
|
|
warehouse_type?: string;
|
|
}
|
|
|
|
/** CartItemWithId -> display parsed result */
|
|
interface CartItemParsed {
|
|
id: string;
|
|
rowKey: string;
|
|
dbId: string;
|
|
source_table: string;
|
|
source_id: string;
|
|
instruction_no: string;
|
|
item_code: string;
|
|
item_name: string;
|
|
spec: string;
|
|
material: string;
|
|
plan_qty: number;
|
|
remain_qty: number;
|
|
outbound_qty: number;
|
|
unit_price: number;
|
|
customer_code: string;
|
|
customer_name: string;
|
|
instruction_date?: string;
|
|
inspection_required?: boolean;
|
|
inspection_type?: "self" | "request" | null;
|
|
packages?: PackageEntry[];
|
|
image?: string | null;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helper: CartItemWithId -> CartItemParsed */
|
|
/* ------------------------------------------------------------------ */
|
|
function toCartItemParsed(item: CartItemWithId): CartItemParsed {
|
|
const data = item.row;
|
|
const inspType =
|
|
data.inspection_type === "self"
|
|
? "self"
|
|
: data.inspection_type === "request"
|
|
? "request"
|
|
: null;
|
|
|
|
return {
|
|
id: item.rowKey || String(data.id ?? ""),
|
|
rowKey: item.rowKey,
|
|
dbId: item.cartId || "",
|
|
source_table:
|
|
item.sourceTable ||
|
|
String(data.source_table ?? "shipment_instruction_detail"),
|
|
source_id:
|
|
item.rowKey || String(data.source_id ?? data.detail_id ?? data.id ?? ""),
|
|
instruction_no: String(data.instruction_no ?? ""),
|
|
item_code: String(data.item_code ?? ""),
|
|
item_name: String(data.item_name ?? ""),
|
|
spec: String(data.spec ?? ""),
|
|
material: String(data.material ?? ""),
|
|
plan_qty: Number(data.plan_qty ?? 0),
|
|
remain_qty: Number(data.remain_qty ?? 0),
|
|
outbound_qty: item.quantity,
|
|
unit_price: Number(data.unit_price ?? 0),
|
|
customer_code: String(data.customer_code ?? ""),
|
|
customer_name: String(data.customer_name ?? ""),
|
|
instruction_date: data.instruction_date
|
|
? String(data.instruction_date)
|
|
: undefined,
|
|
inspection_type: inspType,
|
|
inspection_required: inspType === "self",
|
|
packages: item.packageEntries as unknown as PackageEntry[] | undefined,
|
|
image: data.image ? String(data.image) : null,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function OutboundCartPage() {
|
|
const router = useRouter();
|
|
|
|
/* Cart sync hook */
|
|
const cart = useCartSync("pop-sales-outbound", "shipment_instruction_detail");
|
|
|
|
/* Derived: parsed items from cart */
|
|
const items = useMemo(
|
|
() => cart.cartItems.map(toCartItemParsed),
|
|
[cart.cartItems],
|
|
);
|
|
|
|
/* Inspection results (local overlay, keyed by rowKey) */
|
|
const [inspectionResults, setInspectionResults] = useState<
|
|
Map<string, InspectionResult>
|
|
>(new Map());
|
|
|
|
/* Selection */
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
|
|
|
/* Auto-select all when items change */
|
|
useEffect(() => {
|
|
if (items.length > 0) {
|
|
setSelectedItems(new Set(items.map((i) => i.id)));
|
|
}
|
|
}, [items]);
|
|
|
|
/* Warehouse */
|
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
|
|
const [warehousePickerOpen, setWarehousePickerOpen] = useState(false);
|
|
|
|
/* Outbound number */
|
|
const [outboundNumber, setOutboundNumber] = useState<string>("");
|
|
|
|
/* Confirm result modal */
|
|
const [confirmResult, setConfirmResult] = useState<{
|
|
outboundNumber: string;
|
|
items: CartItemParsed[];
|
|
warehouse: string;
|
|
date: string;
|
|
} | null>(null);
|
|
|
|
/* Outbound date */
|
|
const [outboundDate, setOutboundDate] = useState<string>(
|
|
new Date().toISOString().slice(0, 10),
|
|
);
|
|
|
|
/* Confirm state */
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [resultMsg, setResultMsg] = useState<string | null>(null);
|
|
|
|
/* Inspection modal */
|
|
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
|
const [inspectionTarget, setInspectionTarget] =
|
|
useState<CartItemParsed | null>(null);
|
|
|
|
/* Numpad modal (for qty edit) */
|
|
const [numpadOpen, setNumpadOpen] = useState(false);
|
|
const [numpadTarget, setNumpadTarget] = useState<CartItemParsed | null>(null);
|
|
|
|
/* Derived: customer name (all items should be same customer) */
|
|
const customerName = items.length > 0 ? items[0].customer_name : "";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Fetch warehouses */
|
|
/* ------------------------------------------------------------------ */
|
|
const fetchedRef = useRef(false);
|
|
|
|
const fetchWarehouses = useCallback(async () => {
|
|
try {
|
|
const res = await apiClient.get("/outbound/warehouses");
|
|
const data: Warehouse[] = res.data?.data ?? [];
|
|
setWarehouses(data);
|
|
if (data.length > 0) {
|
|
setSelectedWarehouse(data[0].warehouse_code);
|
|
}
|
|
} catch {
|
|
/* keep empty */
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fetchedRef.current) return;
|
|
fetchedRef.current = true;
|
|
fetchWarehouses();
|
|
}, [fetchWarehouses]);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Selection */
|
|
/* ------------------------------------------------------------------ */
|
|
const toggleSelect = (id: string) => {
|
|
setSelectedItems((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedItems.size === items.length) {
|
|
setSelectedItems(new Set());
|
|
} else {
|
|
setSelectedItems(new Set(items.map((i) => i.id)));
|
|
}
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Qty edit via numpad */
|
|
/* ------------------------------------------------------------------ */
|
|
const openNumpad = (item: CartItemParsed) => {
|
|
setNumpadTarget(item);
|
|
setNumpadOpen(true);
|
|
};
|
|
|
|
const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => {
|
|
if (!numpadTarget) return;
|
|
const finalQty = Math.min(qty, numpadTarget.remain_qty);
|
|
|
|
cart.updateItemQuantity(
|
|
numpadTarget.rowKey,
|
|
finalQty,
|
|
undefined,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
packages.length > 0 ? (packages as any) : undefined,
|
|
);
|
|
setNumpadTarget(null);
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Remove item */
|
|
/* ------------------------------------------------------------------ */
|
|
const handleRemove = (rowKey: string) => {
|
|
cart.removeItem(rowKey);
|
|
setSelectedItems((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(rowKey);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
/* Auto-save: persist dirty changes to DB after a short debounce */
|
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (!cart.isDirty || cart.syncStatus === "saving") return;
|
|
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = setTimeout(() => {
|
|
cart.saveToDb().catch(() => {});
|
|
}, 500);
|
|
return () => {
|
|
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
};
|
|
}, [cart.isDirty, cart.syncStatus, cart]);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Inspection */
|
|
/* ------------------------------------------------------------------ */
|
|
const openInspection = (item: CartItemParsed) => {
|
|
setInspectionTarget(item);
|
|
setInspectionModalOpen(true);
|
|
};
|
|
|
|
const handleInspectionComplete = (result: InspectionResult) => {
|
|
if (!inspectionTarget) return;
|
|
setInspectionResults((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(inspectionTarget.rowKey, result);
|
|
return next;
|
|
});
|
|
setInspectionTarget(null);
|
|
};
|
|
|
|
const handlePassInspection = (rowKey: string) => {
|
|
const item = items.find((i) => i.rowKey === rowKey);
|
|
if (!item) return;
|
|
setInspectionResults((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(rowKey, {
|
|
items: [],
|
|
goodQty: item.outbound_qty,
|
|
badQty: 0,
|
|
remark: "pass",
|
|
completed: true,
|
|
});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const getInspectionResult = (rowKey: string): InspectionResult | null => {
|
|
return inspectionResults.get(rowKey) || null;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Validation: required inspections */
|
|
/* ------------------------------------------------------------------ */
|
|
const selectedItemsList = items.filter((i) => selectedItems.has(i.id));
|
|
|
|
const hasUnfinishedRequiredInspection = selectedItemsList.some(
|
|
(item) =>
|
|
item.inspection_required &&
|
|
item.inspection_type === "self" &&
|
|
!getInspectionResult(item.rowKey)?.completed,
|
|
);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Confirm outbound */
|
|
/* ------------------------------------------------------------------ */
|
|
const handleConfirm = async () => {
|
|
if (selectedItemsList.length === 0) return;
|
|
|
|
if (!selectedWarehouse) {
|
|
setResultMsg("오류: 출고 창고를 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
if (hasUnfinishedRequiredInspection) {
|
|
setResultMsg("오류: 필수 검사를 완료해주세요.");
|
|
return;
|
|
}
|
|
|
|
setConfirming(true);
|
|
setResultMsg(null);
|
|
|
|
try {
|
|
// Generate outbound number at confirm time
|
|
// POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본)
|
|
// 출고 장바구니 전용 screen_id 7010
|
|
let finalNumber = "";
|
|
try {
|
|
const settingsRes: any = await apiClient
|
|
.get("/screen-management/screens/7010/layout-pop")
|
|
.catch(() => null);
|
|
const ruleId =
|
|
settingsRes?.data?.data?.settings?.popConfig?.outbound
|
|
?.numberingRuleId;
|
|
const url =
|
|
ruleId && ruleId !== "__none__"
|
|
? `/outbound/generate-number?ruleId=${encodeURIComponent(ruleId)}`
|
|
: "/outbound/generate-number";
|
|
const numRes = await apiClient.get(url);
|
|
if (numRes.data?.success && numRes.data?.data) {
|
|
finalNumber = numRes.data.data;
|
|
setOutboundNumber(finalNumber);
|
|
}
|
|
} catch {
|
|
/* backend will handle */
|
|
}
|
|
|
|
// POST /api/outbound -- matches outboundController.create
|
|
const payload = {
|
|
outbound_number: finalNumber,
|
|
outbound_date: outboundDate,
|
|
warehouse_code: selectedWarehouse,
|
|
items: selectedItemsList.map((item) => ({
|
|
outbound_type: "판매출고",
|
|
item_code: item.item_code,
|
|
item_name: item.item_name,
|
|
specification: item.spec || "",
|
|
spec: item.spec || "",
|
|
material: item.material || "",
|
|
unit: "EA",
|
|
outbound_qty: item.outbound_qty,
|
|
unit_price: item.unit_price || 0,
|
|
total_amount: (item.outbound_qty || 0) * (item.unit_price || 0),
|
|
reference_number: item.instruction_no,
|
|
customer_code: item.customer_code,
|
|
customer_name: item.customer_name,
|
|
source_type: "shipment_instruction_detail",
|
|
source_id: item.source_id || item.id,
|
|
outbound_status: "출고완료",
|
|
})),
|
|
};
|
|
|
|
const res = await apiClient.post("/outbound", payload);
|
|
|
|
if (res.data?.success) {
|
|
// Remove confirmed items from cart - direct DB delete for reliability
|
|
const confirmedItems = [...selectedItemsList];
|
|
const { dataApi } = await import("@/lib/api/data");
|
|
const confirmPromises = confirmedItems
|
|
.filter((item) => item.dbId)
|
|
.map((item) =>
|
|
dataApi
|
|
.updateRecord("cart_items", item.dbId, { status: "confirmed" })
|
|
.catch(() => {}),
|
|
);
|
|
await Promise.all(confirmPromises);
|
|
|
|
// Also clean up local state via useCartSync
|
|
for (const item of confirmedItems) {
|
|
cart.removeItem(item.rowKey);
|
|
}
|
|
// Reload from DB to sync state
|
|
await cart.loadFromDb();
|
|
|
|
const outNo = finalNumber || "";
|
|
|
|
setConfirmResult({
|
|
outboundNumber: outNo,
|
|
items: confirmedItems,
|
|
warehouse:
|
|
warehouses.find((w) => w.warehouse_code === selectedWarehouse)
|
|
?.warehouse_name || selectedWarehouse,
|
|
date: outboundDate,
|
|
});
|
|
setResultMsg(null);
|
|
} else {
|
|
setResultMsg(
|
|
`오류: ${res.data?.message || "출고 등록에 실패했습니다."}`,
|
|
);
|
|
}
|
|
} catch (err: unknown) {
|
|
// axios 에러 우선: 백엔드 message가 있으면 그것을 표시 (재고 부족 등)
|
|
const e = err as {
|
|
response?: { data?: { message?: string } };
|
|
message?: string;
|
|
};
|
|
const backendMsg = e?.response?.data?.message;
|
|
const msg = backendMsg || e?.message || "출고 등록에 실패했습니다.";
|
|
setResultMsg(`오류: ${msg}`);
|
|
} finally {
|
|
setConfirming(false);
|
|
}
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
const selectedWarehouseName =
|
|
warehouses.find((w) => w.warehouse_code === selectedWarehouse)
|
|
?.warehouse_name || selectedWarehouse;
|
|
|
|
const totalQty = selectedItemsList.reduce((s, i) => s + i.outbound_qty, 0);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Render */
|
|
/* ------------------------------------------------------------------ */
|
|
return (
|
|
<div className="flex flex-col gap-4 pb-4">
|
|
{/* ===== Header ===== */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M15.75 19.5L8.25 12l7.5-7.5"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<div>
|
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
|
|
출고 장바구니
|
|
</h1>
|
|
{customerName && (
|
|
<p className="text-xs text-gray-400 mt-0.5">{customerName}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Confirm button (header only) */}
|
|
<button
|
|
onClick={handleConfirm}
|
|
disabled={
|
|
confirming ||
|
|
selectedItemsList.length === 0 ||
|
|
hasUnfinishedRequiredInspection
|
|
}
|
|
className="flex items-center gap-1.5 px-4 h-10 rounded-xl text-sm font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
|
|
style={{
|
|
background:
|
|
selectedItemsList.length > 0 && !hasUnfinishedRequiredInspection
|
|
? "linear-gradient(to bottom, #4ade80, #16a34a)"
|
|
: "#d1d5db",
|
|
boxShadow:
|
|
selectedItemsList.length > 0 && !hasUnfinishedRequiredInspection
|
|
? "0 4px 12px rgba(22,163,74,0.3)"
|
|
: "none",
|
|
}}
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
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>
|
|
{confirming ? "처리중..." : "확정"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ===== Info banner ===== */}
|
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
|
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-50">
|
|
{customerName && (
|
|
<span className="text-xs font-semibold text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
|
|
{customerName}
|
|
</span>
|
|
)}
|
|
<span className="text-[11px] text-gray-400">{outboundDate}</span>
|
|
{selectedWarehouseName && (
|
|
<span className="text-[11px] text-gray-400">
|
|
| {selectedWarehouseName}
|
|
</span>
|
|
)}
|
|
<span className="ml-auto text-[11px] font-mono text-gray-400">
|
|
{outboundNumber || "확정 시 자동생성"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Info fields: 3 columns */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
{/* Outbound date */}
|
|
<div>
|
|
<label className="text-[11px] font-semibold text-gray-400 mb-1 block">
|
|
출고일자
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={outboundDate}
|
|
onChange={(e) => setOutboundDate(e.target.value)}
|
|
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-green-400 focus:ring-1 focus:ring-green-100 bg-white"
|
|
/>
|
|
</div>
|
|
|
|
{/* Warehouse selector */}
|
|
<div>
|
|
<label className="text-[11px] font-semibold text-gray-400 mb-1 block">
|
|
창고
|
|
</label>
|
|
<button
|
|
onClick={() => setWarehousePickerOpen(true)}
|
|
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left outline-none hover:border-green-300 transition-all bg-white flex items-center justify-between"
|
|
>
|
|
<span
|
|
className={
|
|
selectedWarehouse
|
|
? "text-gray-900 font-medium"
|
|
: "text-gray-400"
|
|
}
|
|
>
|
|
{selectedWarehouseName || "창고 선택"}
|
|
</span>
|
|
<svg
|
|
className="w-4 h-4 text-gray-400 shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M8.25 15l3.75-3.75L15.75 15"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Outbound number (readonly) */}
|
|
<div>
|
|
<label className="text-[11px] font-semibold text-gray-400 mb-1 block">
|
|
출고번호
|
|
</label>
|
|
<div className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm bg-gray-50 font-mono">
|
|
{outboundNumber ? (
|
|
<span className="text-gray-700">{outboundNumber}</span>
|
|
) : (
|
|
<span className="text-gray-400">확정 시 자동생성</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ===== Select all bar ===== */}
|
|
{items.length > 0 && (
|
|
<div className="flex items-center justify-between px-1">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={toggleSelectAll}
|
|
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all shrink-0 ${
|
|
selectedItems.size === items.length
|
|
? "bg-green-500 border-green-500"
|
|
: "border-gray-300 hover:border-green-400"
|
|
}`}
|
|
>
|
|
{selectedItems.size === items.length && (
|
|
<svg
|
|
className="w-3.5 h-3.5 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>
|
|
)}
|
|
</button>
|
|
<span className="text-sm font-semibold text-gray-700">
|
|
담은 품목 <span className="text-green-600">{items.length}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
onClick={toggleSelectAll}
|
|
className="text-xs text-green-500 font-medium hover:underline"
|
|
>
|
|
{selectedItems.size === items.length ? "선택 해제" : "전체 선택"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== Items list ===== */}
|
|
{cart.loading ? (
|
|
<div className="flex items-center justify-center py-16 text-sm text-gray-400">
|
|
<svg
|
|
className="animate-spin w-5 h-5 mr-2 text-green-500"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
불러오는 중...
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
|
<svg
|
|
className="w-16 h-16 mb-4 opacity-20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={1}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm font-medium text-gray-500 mb-1">
|
|
담은 품목이 없습니다
|
|
</p>
|
|
<p className="text-xs text-gray-400 mb-4">
|
|
판매출고 화면에서 품목을 담아주세요
|
|
</p>
|
|
<button
|
|
onClick={() => router.push("/pop/outbound/sales")}
|
|
className="px-4 py-2.5 rounded-xl text-sm font-semibold text-white active:scale-95 transition-all"
|
|
style={{
|
|
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
|
|
boxShadow: "0 4px 12px rgba(34,197,94,0.3)",
|
|
}}
|
|
>
|
|
판매출고로 이동
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{items.map((item) => {
|
|
const inspResult = getInspectionResult(item.rowKey);
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className={`relative rounded-xl border p-3 transition-all ${
|
|
selectedItems.has(item.id)
|
|
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
|
|
: "border-gray-200 bg-white"
|
|
}`}
|
|
>
|
|
{/* Green left bar for selected items */}
|
|
{selectedItems.has(item.id) && (
|
|
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
|
|
)}
|
|
|
|
{/* === Header row: checkbox + item code + item name + inspection badge === */}
|
|
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
|
|
{/* Checkbox */}
|
|
<button
|
|
onClick={() => toggleSelect(item.id)}
|
|
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all shrink-0 ${
|
|
selectedItems.has(item.id)
|
|
? "bg-green-500 border-green-500"
|
|
: "border-gray-300 hover:border-green-400"
|
|
}`}
|
|
>
|
|
{selectedItems.has(item.id) && (
|
|
<svg
|
|
className="w-3.5 h-3.5 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>
|
|
)}
|
|
</button>
|
|
<span className="text-[11px] text-gray-400 font-medium shrink-0">
|
|
{item.item_code}
|
|
</span>
|
|
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">
|
|
{item.item_name}
|
|
</span>
|
|
{item.inspection_type === "self" && (
|
|
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
|
|
검사 필수
|
|
</span>
|
|
)}
|
|
{item.inspection_type === "request" && (
|
|
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-200 shrink-0 whitespace-nowrap">
|
|
검사의뢰 선택
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* === Body row: image + info + action === */}
|
|
<div className="flex gap-2.5">
|
|
{/* Product image */}
|
|
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt={item.item_name}
|
|
className="w-full h-full object-cover rounded-lg"
|
|
/>
|
|
) : (
|
|
<span className="text-2xl text-gray-300">
|
|
{"\uD83D\uDCE6"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info columns */}
|
|
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
|
|
<div className="flex items-center gap-1.5 text-[11px]">
|
|
<span className="text-gray-400 min-w-[45px] shrink-0">
|
|
지시일
|
|
</span>
|
|
<span className="font-medium text-gray-700">
|
|
{item.instruction_date || "-"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-[11px]">
|
|
<span className="text-gray-400 min-w-[45px] shrink-0">
|
|
지시번호
|
|
</span>
|
|
<span className="font-medium text-gray-700 truncate">
|
|
{item.instruction_no || "-"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-[11px]">
|
|
<span className="text-gray-400 min-w-[45px] shrink-0">
|
|
지시수량
|
|
</span>
|
|
<span className="font-medium text-gray-700">
|
|
{item.plan_qty.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-[11px]">
|
|
<span className="text-gray-400 min-w-[45px] shrink-0">
|
|
미출고
|
|
</span>
|
|
<span className="font-bold text-red-500">
|
|
{item.remain_qty.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action column: qty display + delete button */}
|
|
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
|
|
{/* Qty display - clickable to open numpad */}
|
|
<button
|
|
onClick={() => openNumpad(item)}
|
|
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all bg-green-50 border-green-200 hover:bg-green-100 cursor-pointer active:scale-95"
|
|
>
|
|
<span
|
|
className="text-sm font-bold text-green-700"
|
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{item.outbound_qty.toLocaleString()}
|
|
</span>
|
|
<span className="text-[10px] text-gray-400">EA</span>
|
|
</button>
|
|
|
|
{/* Delete button */}
|
|
<button
|
|
onClick={() => handleRemove(item.rowKey)}
|
|
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
|
|
>
|
|
<svg
|
|
className="w-3.5 h-3.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
/>
|
|
</svg>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* === Package info === */}
|
|
{item.packages && item.packages.length > 0 && (
|
|
<div className="mt-2.5 px-3 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[10px] font-bold text-white bg-gradient-to-r from-green-500 to-green-600 px-2 py-0.5 rounded-full">
|
|
포장완료
|
|
</span>
|
|
<span className="text-[11px] font-semibold text-green-600">
|
|
{item.packages
|
|
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
|
|
.toLocaleString()}{" "}
|
|
EA
|
|
</span>
|
|
</div>
|
|
{item.packages.map((pkg, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center gap-1.5 text-[11px] text-gray-600"
|
|
>
|
|
<span>{pkg.unit.icon}</span>
|
|
<span>
|
|
{pkg.count}
|
|
{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA
|
|
= {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* === Inspection row === */}
|
|
{(item.inspection_type === "self" ||
|
|
item.inspection_type === "request") && (
|
|
<div className="mt-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => openInspection(item)}
|
|
className={`flex-1 flex items-center gap-2 px-3 py-2.5 rounded-lg border text-left transition-all active:scale-[0.98] ${
|
|
inspResult?.completed
|
|
? "bg-green-50 border-green-300"
|
|
: item.inspection_type === "self"
|
|
? "bg-blue-50 border-blue-200 hover:bg-blue-100"
|
|
: "bg-amber-50 border-amber-200 hover:bg-amber-100"
|
|
}`}
|
|
>
|
|
<span className="text-[13px] font-semibold">
|
|
{item.inspection_type === "self"
|
|
? "검사"
|
|
: "검사의뢰"}
|
|
</span>
|
|
<span
|
|
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
|
item.inspection_required
|
|
? "bg-red-100 text-red-600"
|
|
: "bg-blue-100 text-blue-600"
|
|
}`}
|
|
>
|
|
{item.inspection_required ? "필수" : "선택"}
|
|
</span>
|
|
<span
|
|
className={`ml-auto text-[12px] font-semibold ${
|
|
inspResult?.completed
|
|
? "text-green-600"
|
|
: "text-gray-400"
|
|
}`}
|
|
>
|
|
{inspResult?.completed ? "완료" : "대기"}
|
|
</span>
|
|
<svg
|
|
className="w-4 h-4 text-gray-400 shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{!item.inspection_required && !inspResult?.completed && (
|
|
<button
|
|
onClick={() => handlePassInspection(item.rowKey)}
|
|
className="px-3 py-2.5 rounded-lg border border-gray-200 bg-gray-50 text-xs font-semibold text-gray-500 hover:bg-gray-100 active:scale-95 transition-all whitespace-nowrap"
|
|
>
|
|
패스
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== Footer summary ===== */}
|
|
{items.length > 0 && (
|
|
<div className="sticky bottom-0 bg-white border-t border-gray-200 rounded-t-2xl shadow-lg -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-4 mt-2">
|
|
{resultMsg && (
|
|
<div
|
|
className={`mb-3 p-3 rounded-xl text-sm font-medium ${
|
|
resultMsg.startsWith("오류")
|
|
? "bg-red-50 text-red-700"
|
|
: "bg-green-50 text-green-700"
|
|
}`}
|
|
>
|
|
{resultMsg}
|
|
</div>
|
|
)}
|
|
|
|
{hasUnfinishedRequiredInspection && (
|
|
<div className="mb-3 p-3 rounded-xl text-sm font-medium bg-amber-50 text-amber-700 border border-amber-200">
|
|
필수 검사를 완료해주세요. 검사 미완료 품목이 있어 확정할 수
|
|
없습니다.
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-500">
|
|
선택{" "}
|
|
<span className="font-bold text-gray-900">
|
|
{selectedItemsList.length}
|
|
</span>
|
|
/{items.length}건
|
|
</span>
|
|
<span className="text-gray-500">
|
|
합계 수량:{" "}
|
|
<span className="font-bold text-green-600">
|
|
{totalQty.toLocaleString()}
|
|
</span>{" "}
|
|
EA
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== Warehouse picker modal ===== */}
|
|
{warehousePickerOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/50"
|
|
onClick={() => setWarehousePickerOpen(false)}
|
|
/>
|
|
<div className="relative bg-white w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl max-h-[80vh] flex flex-col shadow-2xl z-10 overflow-hidden">
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
|
<h3 className="text-lg font-bold text-gray-900">창고 선택</h3>
|
|
<button
|
|
onClick={() => setWarehousePickerOpen(false)}
|
|
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{warehouses.length === 0 ? (
|
|
<p className="text-center text-sm text-gray-400 py-8">
|
|
등록된 창고가 없습니다
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{warehouses.map((wh) => (
|
|
<button
|
|
key={wh.warehouse_code}
|
|
onClick={() => {
|
|
setSelectedWarehouse(wh.warehouse_code);
|
|
setWarehousePickerOpen(false);
|
|
}}
|
|
className={`flex items-center gap-3 p-4 rounded-xl border transition-all active:scale-[0.98] text-left ${
|
|
selectedWarehouse === wh.warehouse_code
|
|
? "border-green-400 bg-green-50 shadow-sm"
|
|
: "border-gray-200 bg-white hover:border-green-300"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
|
|
selectedWarehouse === wh.warehouse_code
|
|
? "bg-green-500 text-white"
|
|
: "bg-gray-100 text-gray-400"
|
|
}`}
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
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 0h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-gray-900">
|
|
{wh.warehouse_name}
|
|
</p>
|
|
<p className="text-[11px] text-gray-400">
|
|
{wh.warehouse_code}
|
|
{wh.warehouse_type ? ` | ${wh.warehouse_type}` : ""}
|
|
</p>
|
|
</div>
|
|
{selectedWarehouse === wh.warehouse_code && (
|
|
<svg
|
|
className="w-5 h-5 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>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== Inspection Modal ===== */}
|
|
{inspectionTarget && (
|
|
<InspectionModal
|
|
open={inspectionModalOpen}
|
|
onClose={() => {
|
|
setInspectionModalOpen(false);
|
|
setInspectionTarget(null);
|
|
}}
|
|
onComplete={handleInspectionComplete}
|
|
itemCode={inspectionTarget.item_code}
|
|
itemName={inspectionTarget.item_name}
|
|
totalQty={inspectionTarget.outbound_qty}
|
|
initialResult={getInspectionResult(inspectionTarget.rowKey)}
|
|
/>
|
|
)}
|
|
|
|
{/* ===== NumberPad Modal (qty edit) ===== */}
|
|
{numpadTarget && (
|
|
<NumberPadModal
|
|
open={numpadOpen}
|
|
onClose={() => {
|
|
setNumpadOpen(false);
|
|
setNumpadTarget(null);
|
|
}}
|
|
onConfirm={handleNumpadConfirm}
|
|
maxQty={numpadTarget.remain_qty}
|
|
itemName={numpadTarget.item_name}
|
|
initialQty={numpadTarget.outbound_qty}
|
|
initialPackages={numpadTarget.packages}
|
|
/>
|
|
)}
|
|
|
|
{/* ===== 출고 완료 결과 모달 ===== */}
|
|
{confirmResult && (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/50" />
|
|
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden z-10">
|
|
{/* Header */}
|
|
<div
|
|
className="px-6 py-5 text-center"
|
|
style={{
|
|
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
|
|
}}
|
|
>
|
|
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-3">
|
|
<svg
|
|
className="w-8 h-8 text-white"
|
|
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>
|
|
<h3 className="text-xl font-bold text-white">출고 처리 완료</h3>
|
|
<p className="text-white/80 text-lg font-mono mt-1">
|
|
{confirmResult.outboundNumber}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="px-6 py-4">
|
|
<div className="flex items-center justify-between text-sm text-gray-500 mb-3">
|
|
<span>
|
|
창고:{" "}
|
|
<span className="font-semibold text-gray-900">
|
|
{confirmResult.warehouse}
|
|
</span>
|
|
</span>
|
|
<span>{confirmResult.date}</span>
|
|
</div>
|
|
|
|
<div className="text-xs font-semibold text-gray-400 mb-2">
|
|
처리된 품목 ({confirmResult.items.length}건)
|
|
</div>
|
|
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-2">
|
|
{confirmResult.items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center justify-between p-3 bg-green-50 rounded-xl border border-green-100"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-gray-900 truncate">
|
|
{item.item_name}
|
|
</p>
|
|
<p className="text-[11px] text-gray-400">
|
|
{item.item_code}
|
|
</p>
|
|
</div>
|
|
<span className="text-sm font-bold text-green-600 ml-3">
|
|
{item.outbound_qty?.toLocaleString()} EA
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* OK button */}
|
|
<div className="px-6 py-4 border-t border-gray-100">
|
|
<button
|
|
onClick={() => {
|
|
setConfirmResult(null);
|
|
router.push("/pop/outbound");
|
|
}}
|
|
className="w-full h-12 rounded-xl text-white font-bold text-base active:scale-[0.98] transition-all"
|
|
style={{
|
|
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
|
|
boxShadow: "0 4px 12px rgba(34,197,94,.3)",
|
|
}}
|
|
>
|
|
확인
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|