Files
vexplor_dev/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx
SeongHyun Kim 327b4d01c2
Some checks failed
Build and Push Images / build-and-push (push) Failing after 49s
feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 자동 창고 매칭
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단
- 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선
- 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거
- 검사관리+입출고관리: 신규 화면 (quality, inventory)
- 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
2026-04-09 14:38:28 +09:00

1315 lines
44 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 "./InspectionModal";
import { NumberPadModal, type PackageEntry } from "./NumberPadModal";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Warehouse {
warehouse_code: string;
warehouse_name: string;
warehouse_type?: string;
}
/** CartItemWithId -> 화면 표시용 파싱 결과 */
interface CartItemParsed {
id: string;
rowKey: string;
dbId: string;
source_table: string;
source_id: string;
purchase_no: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
remain_qty: number;
inbound_qty: number;
unit_price: number;
supplier_code: string;
supplier_name: string;
order_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 ?? "purchase_detail"),
source_id: item.rowKey || String(data.id ?? ""),
purchase_no: String(data.purchase_no ?? ""),
item_code: String(data.item_code ?? ""),
item_name: String(data.item_name ?? ""),
spec: String(data.spec ?? ""),
material: String(data.material ?? ""),
order_qty: Number(data.order_qty ?? 0),
remain_qty: Number(data.remain_qty ?? 0),
inbound_qty: item.quantity,
unit_price: Number(data.unit_price ?? 0),
supplier_code: String(data.supplier_code ?? ""),
supplier_name: String(data.supplier_name ?? ""),
order_date: data.order_date ? String(data.order_date) : undefined,
inspection_type: inspType,
inspection_required: inspType === "self",
// packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[]
packages: item.packageEntries as unknown as PackageEntry[] | undefined,
image: data.image ? String(data.image) : null,
};
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InboundCartPage() {
const router = useRouter();
/* Cart sync hook */
const cart = useCartSync("pop-purchase-inbound", "purchase_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]);
/* Sync inspectionResults with cart.row.inspectionResult
* 페이지 새로고침/재진입 시 cart_items에 저장된 inspectionResult를 Map으로 복원.
* 주의: delete는 명시적 검사 취소(handleCancel)에서만 처리.
* (cart.saveToDb 후 row JSON이 stale할 수 있어 delete 로직은 race condition 유발) */
useEffect(() => {
setInspectionResults((prev) => {
const next = new Map(prev);
let changed = false;
cart.cartItems.forEach((c) => {
const stored = (c.row as Record<string, unknown>)?.inspectionResult;
if (stored && typeof stored === "object") {
// 유효한 검사 결과 → Map에 추가 (덮어쓰지 않음, 로컬 우선)
if (!next.has(c.rowKey)) {
next.set(c.rowKey, stored as InspectionResult);
changed = true;
}
}
// null/undefined여도 Map에서 자동 제거하지 않음 — 명시적 cancel만 처리
});
// 카트에서 사라진 rowKey만 정리 (실제 카트 삭제 시)
const cartKeys = new Set(cart.cartItems.map((c) => c.rowKey));
Array.from(next.keys()).forEach((k) => {
if (!cartKeys.has(k)) {
next.delete(k);
changed = true;
}
});
return changed ? next : prev;
});
}, [cart.cartItems]);
/* Warehouse */
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
const [warehousePickerOpen, setWarehousePickerOpen] = useState(false);
/* Inbound number */
const [inboundNumber, setInboundNumber] = useState<string>("");
/* Confirm result modal */
const [confirmResult, setConfirmResult] = useState<{
inboundNumber: string;
items: CartItemParsed[];
warehouse: string;
date: string;
} | null>(null);
/* Inbound date */
const [inboundDate, setInboundDate] = 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: supplier name (all items should be same supplier) */
const supplierName = items.length > 0 ? items[0].supplier_name : "";
/* ------------------------------------------------------------------ */
/* Fetch warehouses */
/* ------------------------------------------------------------------ */
const fetchedRef = useRef(false);
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/receiving/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,
// PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅
// eslint-disable-next-line @typescript-eslint/no-explicit-any
packages.length > 0 ? (packages as any) : undefined,
);
setNumpadTarget(null);
// Auto-save effect below will persist change to DB
};
/* ------------------------------------------------------------------ */
/* Remove item */
/* ------------------------------------------------------------------ */
const handleRemove = (rowKey: string) => {
cart.removeItem(rowKey);
setSelectedItems((prev) => {
const next = new Set(prev);
next.delete(rowKey);
return next;
});
// Auto-save effect below will persist change to DB
};
/* 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;
const targetRowKey = inspectionTarget.rowKey;
setInspectionResults((prev) => {
const next = new Map(prev);
next.set(targetRowKey, result);
return next;
});
// cart_items.row_data에 검사 결과 저장 (페이지 새로고침해도 유지)
cart.updateItemRow(targetRowKey, { inspectionResult: result });
setInspectionTarget(null);
// 즉시 DB 저장 (자동저장 디바운스를 기다리지 않음)
setTimeout(() => {
cart
.saveToDb()
.catch((err) => console.error("[검사 결과 저장 실패]", err));
}, 100);
};
/* Pass inspection (non-required only) */
const handlePassInspection = (rowKey: string) => {
const item = items.find((i) => i.rowKey === rowKey);
if (!item) return;
const result: InspectionResult = {
items: [],
goodQty: item.inbound_qty,
badQty: 0,
remark: "pass",
completed: true,
};
setInspectionResults((prev) => {
const next = new Map(prev);
next.set(rowKey, result);
return next;
});
cart.updateItemRow(rowKey, { inspectionResult: result });
};
const getInspectionResult = (rowKey: string): InspectionResult | null => {
return inspectionResults.get(rowKey) || null;
};
/* ------------------------------------------------------------------ */
/* Validation: required inspections */
/* ------------------------------------------------------------------ */
const selectedItemsList = items.filter((i) => selectedItems.has(i.id));
// CEO 정책 (2026-04-09 시연 결정): 검사 필수 항목 미완료 시 확정 차단
// 검사 빠진 입고가 검사관리에서 추적 안 되므로, 입력 시점에 막음
const hasUnfinishedRequiredInspection = selectedItemsList.some(
(item) =>
item.inspection_required &&
item.inspection_type === "self" &&
!getInspectionResult(item.rowKey)?.completed,
);
/* ------------------------------------------------------------------ */
/* Confirm inbound */
/* ------------------------------------------------------------------ */
const handleConfirm = async () => {
if (selectedItemsList.length === 0) return;
if (!selectedWarehouse) {
setResultMsg("오류: 입고 창고를 선택해주세요.");
return;
}
// 검사 미완료여도 확정 가능. 단지 inspection_result에 안 들어가거나 "대기" 상태로 기록.
// (CEO 정책: 입고 자체는 진행, 검사 결과만 누락/대기 상태로 표시)
setConfirming(true);
setResultMsg(null);
try {
// 확정 시점에 채번 (동시접속 충돌 방지)
// POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본)
let finalNumber = "";
try {
const settingsRes: any = await apiClient
.get("/screen-management/screens/6527/layout-pop")
.catch(() => null);
const ruleId =
settingsRes?.data?.data?.settings?.popConfig?.inbound
?.numberingRuleId;
const url =
ruleId && ruleId !== "__none__"
? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}`
: "/receiving/generate-number";
const numRes = await apiClient.get(url);
if (numRes.data?.success && numRes.data?.data) {
finalNumber = numRes.data.data;
setInboundNumber(finalNumber);
}
} catch {
/* backend will handle */
}
// POST /api/receiving -- same payload structure as PC
const payload = {
inbound_number: finalNumber,
inbound_date: inboundDate,
warehouse_code: selectedWarehouse,
inbound_type: "구매입고",
items: selectedItemsList.map((item, idx) => {
const inspResult = getInspectionResult(item.rowKey);
return {
inbound_type: "구매입고",
item_number: item.item_code,
item_name: item.item_name,
spec: item.spec || "",
material: item.material || "",
unit: "EA",
inbound_qty: String(item.inbound_qty),
unit_price: String(item.unit_price || 0),
total_amount: String(
(item.inbound_qty || 0) * (item.unit_price || 0),
),
reference_number: item.purchase_no,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
inbound_status: "입고완료",
inspection_status: inspResult?.completed
? "검사완료"
: item.inspection_required
? "검사대기"
: "합격",
source_table: item.source_table,
source_id: item.source_id || item.id,
seq_no: idx + 1,
};
}),
};
const res = await apiClient.post("/receiving", payload);
if (res.data?.success) {
// 검사 결과를 inspection_result_mng + inspection_result에 저장
const insertedDetails: Array<Record<string, unknown>> =
(res.data?.data?.details as Array<Record<string, unknown>>) ??
(res.data?.data?.items as Array<Record<string, unknown>>) ??
[];
const inboundHeaderNo: string =
(res.data?.data?.header as { inbound_number?: string } | undefined)
?.inbound_number ||
finalNumber ||
"";
const inspectionPromises = selectedItemsList
.map((item, idx) => {
const inspResult = getInspectionResult(item.rowKey);
if (!inspResult?.completed) return null;
const matchedDetail = insertedDetails[idx] ?? {};
const referenceId =
(matchedDetail.id as string) ||
(matchedDetail.detail_id as string) ||
`${inboundHeaderNo}-${idx + 1}`;
const goodQty = inspResult.goodQty || 0;
const badQty = inspResult.badQty || 0;
const totalQty = goodQty + badQty;
const overallJudgment = badQty === 0 ? "합격" : "불합격";
return apiClient
.post("/pop/inspection-result", {
inspectionNumber: inspResult.inspectionNumber,
referenceTable: "inbound_mng",
referenceId,
screenId: "pop_inbound_inspection",
itemId: item.item_id || null,
itemCode: item.item_code,
itemName: item.item_name,
inspectionType: "입고검사",
overallJudgment,
totalQty,
goodQty,
badQty,
defectDescription: badQty > 0 ? `불량 ${badQty}` : "",
memo: inspResult.remark || "",
supplierCode: item.supplier_code || null,
supplierName: item.supplier_name || null,
isCompleted: true,
items: inspResult.items.map((insp) => ({
inspectionInfoId: insp.id || null,
inspectionItemName: insp.inspection_item_name,
inspectionStandard: insp.inspection_standard,
passCriteria: insp.pass_criteria,
isRequired: insp.is_required || "Y",
measuredValue: insp.measured_value || "",
judgment: insp.result || null,
})),
})
.catch((err: unknown) => {
const e = err as { message?: string };
console.error(
"[inspection_result 저장 실패]",
item.item_code,
e?.message,
);
});
})
.filter(Boolean);
if (inspectionPromises.length > 0) {
await Promise.all(inspectionPromises);
}
// 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 inboundNo =
res.data?.data?.header?.inbound_number || finalNumber || "";
// 결과 모달 표시 (바로 이동하지 않음)
setConfirmResult({
inboundNumber: inboundNo,
items: confirmedItems,
warehouse:
warehouses.find((w) => w.warehouse_code === selectedWarehouse)
?.warehouse_name || selectedWarehouse,
date: inboundDate,
});
setResultMsg(null);
} else {
setResultMsg(
`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`,
);
}
} catch (err: unknown) {
const msg =
err instanceof Error ? err.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.inbound_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>
{supplierName && (
<p className="text-xs text-gray-400 mt-0.5">{supplierName}</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, #34d399, #059669)"
: "#d1d5db",
boxShadow:
selectedItemsList.length > 0 && !hasUnfinishedRequiredInspection
? "0 4px 12px rgba(5,150,105,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">
{supplierName && (
<span className="text-xs font-semibold text-blue-700 bg-blue-50 px-2 py-0.5 rounded-full">
{supplierName}
</span>
)}
<span className="text-[11px] text-gray-400">{inboundDate}</span>
{selectedWarehouseName && (
<span className="text-[11px] text-gray-400">
| {selectedWarehouseName}
</span>
)}
<span className="ml-auto text-[11px] font-mono text-gray-400">
{inboundNumber || "확정 시 자동생성"}
</span>
</div>
{/* Info fields: 3 columns */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Inbound date */}
<div>
<label className="text-[11px] font-semibold text-gray-400 mb-1 block">
</label>
<input
type="date"
value={inboundDate}
onChange={(e) => setInboundDate(e.target.value)}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white"
/>
</div>
{/* Warehouse selector - card-style touch button */}
<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-blue-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>
{/* Inbound 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">
{inboundNumber ? (
<span className="text-gray-700">{inboundNumber}</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-blue-500 border-blue-500"
: "border-gray-300 hover:border-blue-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-blue-600">{items.length}</span>
</span>
</div>
<button
onClick={toggleSelectAll}
className="text-xs text-blue-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-blue-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/inbound/purchase")}
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, #60a5fa, #2563eb)",
boxShadow: "0 4px 12px rgba(59,130,246,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-blue-300 bg-gradient-to-br from-blue-50/80 to-indigo-50/50"
: "border-gray-200 bg-white"
}`}
>
{/* Blue left bar for selected items */}
{selectedItems.has(item.id) && (
<div className="absolute top-0 left-0 w-[3px] h-full bg-blue-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-blue-500 border-blue-500"
: "border-gray-300 hover:border-blue-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.order_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.purchase_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.order_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-blue-50 border-blue-200 hover:bg-blue-100 cursor-pointer active:scale-95"
>
<span
className="text-sm font-bold text-blue-700"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{item.inbound_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>
{/* Pass button for non-required */}
{!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>
)}
{/* ===== Result toast (only when message exists) ===== */}
{resultMsg && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<div
className={`px-4 py-3 rounded-xl text-sm font-medium shadow-lg ${
resultMsg.startsWith("오류")
? "bg-red-50 text-red-700 border border-red-200"
: "bg-green-50 text-green-700 border border-green-200"
}`}
>
{resultMsg}
</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">
{/* Header */}
<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>
{/* Warehouse list */}
<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-blue-400 bg-blue-50 shadow-sm"
: "border-gray-200 bg-white hover:border-blue-300"
}`}
>
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
selectedWarehouse === wh.warehouse_code
? "bg-blue-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-blue-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}
onCancel={() => {
// 검사 결과 무효화 (완료 → 대기 풀림)
const targetRowKey = inspectionTarget.rowKey;
setInspectionResults((prev) => {
const next = new Map(prev);
next.delete(targetRowKey);
return next;
});
cart.updateItemRow(targetRowKey, { inspectionResult: null });
setTimeout(() => cart.saveToDb().catch(() => {}), 100);
}}
itemCode={inspectionTarget.item_code}
itemName={inspectionTarget.item_name}
totalQty={inspectionTarget.inbound_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.inbound_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">
{/* 헤더 */}
<div
className="px-6 py-5 text-center"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
<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.inboundNumber}
</p>
</div>
{/* 처리 내역 */}
<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.inbound_qty?.toLocaleString()} EA
</span>
</div>
))}
</div>
</div>
{/* 확인 버튼 */}
<div className="px-6 py-4 border-t border-gray-100">
<button
onClick={() => {
setConfirmResult(null);
router.push("/pop/inbound");
}}
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, #60a5fa, #2563eb)",
boxShadow: "0 4px 12px rgba(59,130,246,.3)",
}}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}