Files
vexplor_dev/frontend/components/pop/hardcoded/inbound/InboundCart.tsx
SeongHyun Kim 9b7b88ff7c
Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
feat: POP 하드코딩 화면 추가 (PC 코드 무변경 재병합)
- POP 전용 39개 파일 추가 (홈/입고/출고/생산)
- 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일)
- POP 전용 API 7개 추가 (창고/위치/입고/동기화)
- PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
2026-04-02 17:39:42 +09:00

529 lines
22 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { InspectionModal, type InspectionResult } from "./InspectionModal";
import type { PackageEntry } from "./NumberPadModal";
/* ------------------------------------------------------------------ */
/* Warehouse type */
/* ------------------------------------------------------------------ */
interface Warehouse {
warehouse_code: string;
warehouse_name: string;
warehouse_type?: string;
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface CartItem {
id: string;
/** cart_items 테이블의 PK (UUID) — DB 삭제용 */
dbId?: string;
/** purchase_detail or purchase_order_mng */
source_table: string;
/** PK of the source row */
source_id: string;
purchase_no: string;
item_code: string;
item_name: string;
spec: string;
material: string;
order_qty: number;
remain_qty: number;
/** User-entered quantity */
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[];
inspectionResult?: InspectionResult | null;
}
interface InboundCartProps {
open: boolean;
onClose: () => void;
items: CartItem[];
onUpdateQty: (id: string, qty: number) => void;
onRemove: (id: string) => void;
onClear: () => void;
supplierName?: string;
onUpdateItems?: (items: CartItem[]) => void;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InboundCart({
open,
onClose,
items,
onUpdateQty,
onRemove,
onClear,
supplierName,
onUpdateItems,
}: InboundCartProps) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [resultMsg, setResultMsg] = useState<string | null>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionTarget, setInspectionTarget] = useState<CartItem | null>(null);
/* Warehouse state */
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
/* Fetch warehouses on mount */
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/receiving/warehouses");
const data: Warehouse[] = res.data?.data ?? [];
setWarehouses(data);
if (data.length > 0 && !selectedWarehouse) {
setSelectedWarehouse(data[0].warehouse_code);
}
} catch {
// Keep empty - user can still confirm without warehouse
}
}, [selectedWarehouse]);
useEffect(() => {
if (open) {
fetchWarehouses();
}
}, [open, fetchWarehouses]);
const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0);
const totalAmount = items.reduce((s, i) => s + i.inbound_qty * i.unit_price, 0);
/* Toggle select */
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)));
}
};
/* Open inspection modal */
const openInspection = (item: CartItem) => {
setInspectionTarget(item);
setInspectionModalOpen(true);
};
/* Handle inspection complete */
const handleInspectionComplete = (result: InspectionResult) => {
if (!inspectionTarget || !onUpdateItems) return;
const updated = items.map((item) =>
item.id === inspectionTarget.id
? { ...item, inspectionResult: result }
: item
);
onUpdateItems(updated);
setInspectionTarget(null);
};
/* Confirm inbound — PC receivingController.create 와 동일한 body 구조 */
const handleConfirm = async () => {
if (items.length === 0) return;
if (!selectedWarehouse) {
setResultMsg("오류: 입고 창고를 선택해주세요.");
return;
}
setConfirming(true);
setResultMsg(null);
try {
// 1. 입고번호 채번 (RCV-YYYY-XXXX)
let inboundNumber: string | undefined;
try {
const numRes = await apiClient.get("/receiving/generate-number");
if (numRes.data?.success && numRes.data?.data) {
inboundNumber = numRes.data.data;
}
} catch {
// 채번 실패 시 백엔드가 처리
}
// 2. POST /api/receiving — PC create 와 동일한 payload
const payload = {
inbound_number: inboundNumber,
inbound_date: new Date().toISOString().slice(0, 10),
warehouse_code: selectedWarehouse,
inbound_type: "구매입고",
items: items.map((item, idx) => ({
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,
inspection_status: item.inspectionResult?.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) {
// 3. cart_items DB 정리 (백그라운드, 논블로킹)
// cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨)
const rowKeys = items.map((item) => item.source_id || item.id).filter(Boolean);
if (rowKeys.length > 0) {
apiClient.post("/pop/execute-action", {
tasks: [{ type: "cart-save" }],
cartChanges: {
toDelete: rowKeys,
},
}).catch(() => {
// cart cleanup 실패 시 무시
});
}
const inboundNo = res.data?.data?.header?.inbound_number || inboundNumber || "";
setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`);
setTimeout(() => {
onClear();
onClose();
router.push("/pop/inbound");
}, 1500);
} else {
setResultMsg(`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "입고 등록에 실패했습니다.";
setResultMsg(`오류: ${msg}`);
} finally {
setConfirming(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-full sm:max-w-lg sm:rounded-2xl rounded-t-2xl max-h-[90vh] 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">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-blue-500 flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} 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>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900"> </h3>
{supplierName && (
<p className="text-xs text-gray-400">{supplierName}</p>
)}
</div>
</div>
<button
onClick={onClose}
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>
{/* Select all bar */}
{items.length > 0 && (
<div className="flex items-center gap-3 px-5 py-2 border-b border-gray-50 bg-gray-50/50">
<button
onClick={toggleSelectAll}
className={`w-5 h-5 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 h-3 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-xs text-gray-500">
({selectedItems.size}/{items.length})
</span>
</div>
)}
{/* Items */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" 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"> </p>
</div>
) : (
<div className="flex flex-col gap-3">
{items.map((item) => (
<div
key={item.id}
className="bg-gray-50 rounded-xl p-3 border border-gray-100"
>
{/* Top row: checkbox + name + delete */}
<div className="flex items-start gap-2.5 mb-2">
{/* Checkbox */}
<button
onClick={() => toggleSelect(item.id)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all shrink-0 mt-0.5 ${
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 h-3 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>
<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 mt-0.5">
{item.item_code} | {item.purchase_no}
</p>
</div>
{/* Delete button */}
<button
onClick={() => onRemove(item.id)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-white bg-red-400 hover:bg-red-500 transition-colors shrink-0"
>
<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>
{/* Spec row */}
{(item.spec || item.material) && (
<p className="text-[11px] text-gray-400 mb-2 ml-[30px]">
{[item.spec, item.material].filter(Boolean).join(" | ")}
</p>
)}
{/* Package info */}
{item.packages && item.packages.length > 0 && (
<div className="ml-[30px] mb-2 px-2.5 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] font-bold text-white bg-green-500 px-1.5 py-0.5 rounded-full">
</span>
<span className="text-[10px] text-green-600 font-semibold">
{"\uD83D\uDCE6"} {item.packages.map(p =>
`${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA`
).join(", ")}
</span>
</div>
</div>
)}
{/* Inspection row */}
{(item.inspection_type === "self" || item.inspection_type === "request") && (
<div className="ml-[30px] mb-2">
<button
onClick={() => openInspection(item)}
className={`flex items-center gap-2 w-full px-3 py-2 rounded-md border text-left transition-all ${
item.inspectionResult?.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 ${
item.inspectionResult?.completed
? "text-green-600"
: "text-gray-400"
}`}>
{item.inspectionResult?.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>
</div>
)}
{/* Qty controls */}
<div className="flex items-center justify-between ml-[30px]">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">: {item.remain_qty.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => onUpdateQty(item.id, Math.max(1, item.inbound_qty - 1))}
className="w-8 h-8 rounded-lg 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-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
</svg>
</button>
<input
type="number"
value={item.inbound_qty}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0) onUpdateQty(item.id, Math.min(v, item.remain_qty));
}}
className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100"
style={{ fontVariantNumeric: "tabular-nums" }}
/>
<button
onClick={() => onUpdateQty(item.id, Math.min(item.remain_qty, item.inbound_qty + 1))}
className="w-8 h-8 rounded-lg 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-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer summary + confirm */}
{items.length > 0 && (
<div className="border-t border-gray-100 px-5 py-4">
{/* Result message */}
{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>
)}
{/* Warehouse selection */}
<div className="mb-3">
<label className="text-xs font-semibold text-gray-500 mb-1 block"> </label>
<select
value={selectedWarehouse}
onChange={(e) => setSelectedWarehouse(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white"
>
{warehouses.length === 0 ? (
<option value=""> </option>
) : (
warehouses.map((wh) => (
<option key={wh.warehouse_code} value={wh.warehouse_code}>
{wh.warehouse_name} ({wh.warehouse_code})
</option>
))
)}
</select>
</div>
{/* Summary */}
<div className="flex items-center justify-between mb-3 text-sm">
<span className="text-gray-500">
<span className="font-bold text-gray-900">{items.length}</span>
</span>
<div className="flex items-center gap-3">
<span className="text-gray-500">
: <span className="font-bold text-blue-600">{totalQty.toLocaleString()}</span>
</span>
{totalAmount > 0 && (
<span className="text-gray-400 text-xs">
({totalAmount.toLocaleString()})
</span>
)}
</div>
</div>
{/* Buttons */}
<div className="flex gap-3">
<button
onClick={() => { onClear(); }}
className="flex-1 h-12 rounded-xl border border-gray-200 text-sm font-semibold text-gray-600 hover:bg-gray-50 active:scale-[0.98] transition-all"
>
</button>
<button
onClick={handleConfirm}
disabled={confirming || items.length === 0}
className="flex-[2] h-12 rounded-xl text-sm font-bold text-white active:scale-[0.98] transition-all disabled:opacity-50"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
boxShadow: "0 4px 12px rgba(59,130,246,.3)",
}}
>
{confirming ? "처리 중..." : `입고 확정 (${items.length}건)`}
</button>
</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.inbound_qty}
initialResult={inspectionTarget.inspectionResult}
/>
)}
</div>
);
}