Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
- POP 전용 39개 파일 추가 (홈/입고/출고/생산) - 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일) - POP 전용 API 7개 추가 (창고/위치/입고/동기화) - PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
649 lines
30 KiB
TypeScript
649 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
|
|
import { NumberPadModal, type PackageEntry } from "./NumberPadModal";
|
|
import { BarcodeScanModal } from "../common/BarcodeScanModal";
|
|
import type { CartItemWithId } from "../common/useCartSync";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface PurchaseOrder {
|
|
id: string;
|
|
purchase_no: string;
|
|
order_date: string;
|
|
supplier_code: string;
|
|
supplier_name: string;
|
|
item_code: string;
|
|
item_name: string;
|
|
spec: string;
|
|
material: string;
|
|
order_qty: number;
|
|
received_qty: number;
|
|
remain_qty: number;
|
|
unit_price: number;
|
|
status: string;
|
|
due_date: string;
|
|
source_table: string;
|
|
/** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */
|
|
inspection_type: "self" | "request" | null;
|
|
/** Item image URL from item_info.image (may be null) */
|
|
image: string | null;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Dummy data (fallback) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const DUMMY_ORDERS: PurchaseOrder[] = [
|
|
{
|
|
id: "dm1", purchase_no: "PO-2026-0401-001", order_date: "2026-03-28",
|
|
supplier_code: "C001", supplier_name: "(주)한국철강",
|
|
item_code: "STS304-2T", item_name: "STS304 2T 판재",
|
|
spec: "2T x 1219 x 2438", material: "STS304", order_qty: 500, received_qty: 0, remain_qty: 500,
|
|
unit_price: 12000, status: "발주확정", due_date: "2026-04-05", source_table: "purchase_detail",
|
|
inspection_type: "self",
|
|
image: null,
|
|
},
|
|
{
|
|
id: "dm2", purchase_no: "PO-2026-0401-001", order_date: "2026-03-28",
|
|
supplier_code: "C001", supplier_name: "(주)한국철강",
|
|
item_code: "STS316-3T", item_name: "STS316 3T 판재",
|
|
spec: "3T x 1219 x 2438", material: "STS316", order_qty: 200, received_qty: 50, remain_qty: 150,
|
|
unit_price: 18000, status: "부분입고", due_date: "2026-04-05", source_table: "purchase_detail",
|
|
inspection_type: "request",
|
|
image: null,
|
|
},
|
|
{
|
|
id: "dm3", purchase_no: "PO-2026-0330-005", order_date: "2026-03-25",
|
|
supplier_code: "C001", supplier_name: "(주)한국철강",
|
|
item_code: "AL-A100", item_name: "알루미늄 프로파일 A100",
|
|
spec: "A100 x 6000L", material: "AL6063", order_qty: 300, received_qty: 100, remain_qty: 200,
|
|
unit_price: 8500, status: "부분입고", due_date: "2026-04-02", source_table: "purchase_detail",
|
|
inspection_type: null,
|
|
image: null,
|
|
},
|
|
{
|
|
id: "dm4", purchase_no: "PO-2026-0329-003", order_date: "2026-03-22",
|
|
supplier_code: "C002", supplier_name: "(주)대한알루미늄",
|
|
item_code: "BOLT-M10", item_name: "볼트 M10x30",
|
|
spec: "M10x30 SUS", material: "SUS304", order_qty: 1000, received_qty: 0, remain_qty: 1000,
|
|
unit_price: 150, status: "발주확정", due_date: "2026-04-01", source_table: "purchase_detail",
|
|
inspection_type: "self",
|
|
image: null,
|
|
},
|
|
{
|
|
id: "dm5", purchase_no: "PO-2026-0329-003", order_date: "2026-03-22",
|
|
supplier_code: "C002", supplier_name: "(주)대한알루미늄",
|
|
item_code: "NUT-M10", item_name: "너트 M10 SUS",
|
|
spec: "M10", material: "SUS304", order_qty: 1000, received_qty: 0, remain_qty: 1000,
|
|
unit_price: 80, status: "발주확정", due_date: "2026-04-01", source_table: "purchase_detail",
|
|
inspection_type: null,
|
|
image: null,
|
|
},
|
|
];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface PurchaseInboundProps {
|
|
/** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */
|
|
cart: import("../common/useCartSync").UseCartSyncReturn;
|
|
}
|
|
|
|
export function PurchaseInbound({ cart }: PurchaseInboundProps) {
|
|
const router = useRouter();
|
|
|
|
/* State */
|
|
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
|
|
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
|
|
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [keyword, setKeyword] = useState("");
|
|
|
|
/* NumberPad state */
|
|
const [numpadOpen, setNumpadOpen] = useState(false);
|
|
const [numpadTarget, setNumpadTarget] = useState<PurchaseOrder | null>(null);
|
|
|
|
/* Barcode scan modal state */
|
|
const [supplierScanOpen, setSupplierScanOpen] = useState(false);
|
|
const [itemScanOpen, setItemScanOpen] = useState(false);
|
|
|
|
/* Inline supplier search state */
|
|
const [supplierSearchText, setSupplierSearchText] = useState("");
|
|
const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false);
|
|
const [allSuppliers, setAllSuppliers] = useState<Supplier[]>([]);
|
|
const supplierInputRef = useRef<HTMLInputElement>(null);
|
|
const supplierDropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
/* Fetch all suppliers for inline search (supplier_mng = 공급사) */
|
|
const fetchAllSuppliers = useCallback(async () => {
|
|
try {
|
|
const res = await apiClient.get("/data/supplier_mng", { params: { pageSize: 500 } });
|
|
const data = res.data?.data ?? res.data?.rows ?? [];
|
|
const list: Supplier[] = (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
|
|
id: String(r.id ?? ""),
|
|
customer_name: String(r.supplier_name ?? r.customer_name ?? r.name ?? ""),
|
|
customer_code: String(r.supplier_code ?? r.customer_code ?? r.code ?? ""),
|
|
business_number: String(r.business_number ?? ""),
|
|
phone: String(r.contact_phone ?? r.phone ?? ""),
|
|
address: String(r.address ?? ""),
|
|
}));
|
|
setAllSuppliers(list);
|
|
} catch {
|
|
setAllSuppliers([]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]);
|
|
|
|
/* Filtered suppliers for inline dropdown */
|
|
const filteredSuppliers = useMemo(() => {
|
|
if (!supplierSearchText.trim()) return [];
|
|
return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim()));
|
|
}, [allSuppliers, supplierSearchText]);
|
|
|
|
/* Close dropdown on outside click */
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
supplierDropdownRef.current &&
|
|
!supplierDropdownRef.current.contains(e.target as Node) &&
|
|
supplierInputRef.current &&
|
|
!supplierInputRef.current.contains(e.target as Node)
|
|
) {
|
|
setSupplierDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
/* Fetch purchase orders */
|
|
const fetchOrders = useCallback(async (searchKeyword?: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = { pageSize: "50" };
|
|
if (searchKeyword) params.keyword = searchKeyword;
|
|
|
|
const res = await apiClient.get("/receiving/source/purchase-orders", { params });
|
|
const data = res.data?.data;
|
|
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
setOrders(data.map((r: Record<string, unknown>) => ({
|
|
id: String(r.id ?? ""),
|
|
purchase_no: String(r.purchase_no ?? ""),
|
|
order_date: String(r.order_date ?? "").slice(0, 10),
|
|
supplier_code: String(r.supplier_code ?? ""),
|
|
supplier_name: String(r.supplier_name ?? ""),
|
|
item_code: String(r.item_code ?? ""),
|
|
item_name: String(r.item_name ?? ""),
|
|
spec: String(r.spec ?? ""),
|
|
material: String(r.material ?? ""),
|
|
order_qty: Number(r.order_qty ?? 0),
|
|
received_qty: Number(r.received_qty ?? 0),
|
|
remain_qty: Number(r.remain_qty ?? 0),
|
|
unit_price: Number(r.unit_price ?? 0),
|
|
status: String(r.status ?? ""),
|
|
due_date: String(r.due_date ?? "").slice(0, 10),
|
|
source_table: String(r.source_table ?? "purchase_detail"),
|
|
inspection_type: r.inspection_type === "self" ? "self"
|
|
: r.inspection_type === "request" ? "request"
|
|
: null,
|
|
image: r.image ? String(r.image) : null,
|
|
})));
|
|
} else {
|
|
setOrders(DUMMY_ORDERS);
|
|
}
|
|
} catch {
|
|
setOrders(DUMMY_ORDERS);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
/* Initial load */
|
|
useEffect(() => {
|
|
fetchOrders();
|
|
}, [fetchOrders]);
|
|
|
|
/* Filter orders by selected supplier */
|
|
const filteredOrders = selectedSupplier
|
|
? orders.filter((o) =>
|
|
o.supplier_code === selectedSupplier.customer_code ||
|
|
o.supplier_name === selectedSupplier.customer_name
|
|
)
|
|
: orders;
|
|
|
|
/* Filter by keyword */
|
|
const displayOrders = keyword
|
|
? filteredOrders.filter((o) =>
|
|
o.item_name.toLowerCase().includes(keyword.toLowerCase()) ||
|
|
o.item_code.toLowerCase().includes(keyword.toLowerCase()) ||
|
|
o.purchase_no.toLowerCase().includes(keyword.toLowerCase())
|
|
)
|
|
: filteredOrders;
|
|
|
|
/* Open numpad for an order */
|
|
const openNumpad = (order: PurchaseOrder) => {
|
|
setNumpadTarget(order);
|
|
setNumpadOpen(true);
|
|
};
|
|
|
|
/* Add to cart with numpad result */
|
|
const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => {
|
|
if (!numpadTarget) return;
|
|
const order = numpadTarget;
|
|
if (cart.isItemInCart(order.id)) return;
|
|
|
|
const finalQty = Math.min(qty, order.remain_qty);
|
|
|
|
cart.addItem(
|
|
{
|
|
row: {
|
|
id: order.id,
|
|
item_code: order.item_code,
|
|
item_name: order.item_name,
|
|
supplier_code: order.supplier_code,
|
|
supplier_name: order.supplier_name,
|
|
purchase_no: order.purchase_no,
|
|
unit_price: order.unit_price || 0,
|
|
spec: order.spec || "",
|
|
material: order.material || "",
|
|
order_qty: order.order_qty,
|
|
remain_qty: order.remain_qty,
|
|
order_date: order.order_date || "",
|
|
inspection_type: order.inspection_type,
|
|
source_table: order.source_table,
|
|
image: order.image || null,
|
|
},
|
|
quantity: finalQty,
|
|
// PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
packageEntries: packages.length > 0 ? packages as any : undefined,
|
|
},
|
|
order.id,
|
|
);
|
|
setNumpadTarget(null);
|
|
};
|
|
|
|
/* Remove from cart (cancel) */
|
|
const handleRemoveFromCart = (id: string) => {
|
|
cart.removeItem(id);
|
|
};
|
|
|
|
/* Search */
|
|
const handleSearch = () => {
|
|
fetchOrders(keyword || undefined);
|
|
};
|
|
|
|
const isInCart = (id: string) => cart.isItemInCart(id);
|
|
const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* ===== Header ===== */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => router.push("/pop/inbound")}
|
|
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>
|
|
<p className="text-xs text-gray-400 mt-0.5">발주 품목을 선택하여 입고하세요</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cart button moved to header via headerRight prop */}
|
|
</div>
|
|
|
|
{/* ===== Search area (2 columns on tablet+) ===== */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
{/* Supplier search card */}
|
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-gray-500">거래처</span>
|
|
{selectedSupplier && (
|
|
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
{selectedSupplier.customer_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="relative flex items-center gap-2">
|
|
<button
|
|
onClick={() => setSupplierModalOpen(true)}
|
|
className={`flex-1 px-3 py-2.5 border rounded-lg text-sm text-left outline-none transition-all ${
|
|
selectedSupplier
|
|
? "bg-green-50/50 border-green-200 text-green-800 font-medium"
|
|
: "border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
{selectedSupplier ? selectedSupplier.customer_name : "거래처를 선택하세요"}
|
|
</button>
|
|
{/* QR/Barcode scan button - glossy v3 */}
|
|
<button
|
|
onClick={() => setSupplierScanOpen(true)}
|
|
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
|
|
style={{
|
|
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
|
boxShadow: "0 4px 12px rgba(59,130,246,0.3)",
|
|
}}
|
|
>
|
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<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 0113.5 9.375v-4.5z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
|
|
</svg>
|
|
</button>
|
|
{selectedSupplier && (
|
|
<button
|
|
onClick={() => { setSelectedSupplier(null); setSupplierSearchText(""); }}
|
|
className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors shrink-0"
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{/* Supplier dropdown removed — use modal instead */}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Item search card */}
|
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-gray-500">발주 품목</span>
|
|
<span className="text-[11px] font-semibold text-white bg-blue-500 px-2 py-0.5 rounded-full min-w-[24px] text-center">
|
|
{selectedSupplier ? displayOrders.length : 0}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
|
placeholder="품목명, 품목코드, 발주번호 검색..."
|
|
disabled={!selectedSupplier}
|
|
className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${
|
|
selectedSupplier
|
|
? "focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
|
: "bg-gray-50 text-gray-400 cursor-not-allowed"
|
|
}`}
|
|
/>
|
|
{/* QR/Barcode scan button - glossy v3 */}
|
|
<button
|
|
onClick={() => setItemScanOpen(true)}
|
|
disabled={!selectedSupplier}
|
|
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${
|
|
!selectedSupplier ? "opacity-40 cursor-not-allowed" : ""
|
|
}`}
|
|
style={{
|
|
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
|
boxShadow: selectedSupplier ? "0 4px 12px rgba(59,130,246,0.3)" : "none",
|
|
}}
|
|
>
|
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<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 0113.5 9.375v-4.5z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 14.625v2.25h2.25m-2.25 2.25h4.5v-4.5" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ===== Order items ===== */}
|
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
|
|
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-50">
|
|
<span className="text-xs font-semibold text-gray-500">
|
|
발주 품목 목록
|
|
</span>
|
|
<span className="text-[11px] text-gray-400">
|
|
{selectedSupplier ? `${displayOrders.length}건` : "-"}
|
|
</span>
|
|
</div>
|
|
|
|
{!selectedSupplier ? (
|
|
<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="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
|
</svg>
|
|
<p className="text-sm font-medium text-gray-500 mb-1">거래처를 먼저 선택하세요</p>
|
|
<p className="text-xs text-gray-400">거래처를 선택하면 해당 거래처의 발주 품목이 표시됩니다</p>
|
|
</div>
|
|
) : loading ? (
|
|
<div className="flex items-center justify-center py-12 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>
|
|
) : displayOrders.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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
<p className="text-sm">
|
|
{selectedSupplier ? "해당 거래처의 미입고 발주가 없습니다" : "거래처를 선택하거나 품목을 검색하세요"}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
{displayOrders.map((order) => {
|
|
const inCart = isInCart(order.id);
|
|
const cartItem = getCartItem(order.id);
|
|
|
|
return (
|
|
<div
|
|
key={order.id}
|
|
className={`relative rounded-xl border p-3 transition-all ${
|
|
inCart
|
|
? "border-green-300 bg-gradient-to-br from-green-50/80 to-emerald-50/50"
|
|
: "border-gray-200 bg-white hover:border-blue-300"
|
|
}`}
|
|
>
|
|
{/* Green left bar for in-cart items */}
|
|
{inCart && (
|
|
<div className="absolute top-0 left-0 w-[3px] h-full bg-green-500 rounded-l-xl" />
|
|
)}
|
|
|
|
{/* === Header row: item code + item name + inspection badge === */}
|
|
<div className="flex items-center gap-1.5 mb-2.5 pb-2 border-b border-gray-100">
|
|
<span className="text-[11px] text-gray-400 font-medium shrink-0">{order.item_code}</span>
|
|
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{order.item_name}</span>
|
|
{order.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>
|
|
)}
|
|
{order.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">
|
|
{order.image ? (
|
|
<img src={order.image} alt={order.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">{order.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">{order.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">{order.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">
|
|
{inCart
|
|
? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString()
|
|
: order.remain_qty.toLocaleString()
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action column: qty display + add/cancel button */}
|
|
<div className="flex flex-col gap-1.5 items-stretch min-w-[80px] shrink-0">
|
|
{/* Qty display - clickable to open numpad */}
|
|
<button
|
|
onClick={() => {
|
|
if (!inCart) openNumpad(order);
|
|
}}
|
|
disabled={inCart}
|
|
className={`flex items-center justify-center gap-1 px-2.5 py-2 rounded-md border transition-all ${
|
|
inCart
|
|
? "bg-gray-50 border-gray-200 cursor-default"
|
|
: "bg-blue-50 border-blue-200 hover:bg-blue-100 cursor-pointer active:scale-95"
|
|
}`}
|
|
>
|
|
<span className={`text-sm font-bold ${inCart ? "text-gray-400" : "text-blue-700"}`}
|
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{inCart
|
|
? (cartItem?.quantity ?? order.remain_qty).toLocaleString()
|
|
: order.remain_qty.toLocaleString()
|
|
}
|
|
</span>
|
|
<span className="text-[10px] text-gray-400">EA</span>
|
|
</button>
|
|
|
|
{/* Add / Cancel button */}
|
|
{inCart ? (
|
|
<button
|
|
onClick={() => handleRemoveFromCart(order.id)}
|
|
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"
|
|
>
|
|
취소
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => openNumpad(order)}
|
|
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md text-white text-xs font-semibold active:scale-95 transition-all"
|
|
style={{
|
|
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
|
|
}}
|
|
>
|
|
<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="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>
|
|
담기
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* === Package info (shown when in cart with packages) === */}
|
|
{(() => {
|
|
// packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[]
|
|
const pkgs = (inCart && cartItem?.packageEntries) ? cartItem.packageEntries as unknown as PackageEntry[] : [];
|
|
return pkgs.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">
|
|
{pkgs.reduce((s, p) => s + p.count * p.qtyPerUnit, 0).toLocaleString()} EA
|
|
</span>
|
|
</div>
|
|
{pkgs.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>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ===== Modals ===== */}
|
|
<SupplierModal
|
|
open={supplierModalOpen}
|
|
onClose={() => setSupplierModalOpen(false)}
|
|
onSelect={(s) => setSelectedSupplier(s)}
|
|
/>
|
|
|
|
<NumberPadModal
|
|
open={numpadOpen}
|
|
onClose={() => { setNumpadOpen(false); setNumpadTarget(null); }}
|
|
onConfirm={handleNumpadConfirm}
|
|
maxQty={numpadTarget?.remain_qty ?? 0}
|
|
itemName={numpadTarget?.item_name ?? ""}
|
|
/>
|
|
|
|
{/* Barcode scan modal for supplier */}
|
|
<BarcodeScanModal
|
|
open={supplierScanOpen}
|
|
onOpenChange={setSupplierScanOpen}
|
|
targetField="거래처"
|
|
autoSubmit
|
|
onScanSuccess={(barcode) => {
|
|
setSupplierScanOpen(false);
|
|
// 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭)
|
|
const match = allSuppliers.find(
|
|
(s) =>
|
|
s.customer_code === barcode ||
|
|
s.customer_name.includes(barcode)
|
|
);
|
|
if (match) {
|
|
setSelectedSupplier(match);
|
|
setSupplierSearchText("");
|
|
} else {
|
|
// 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시
|
|
setSupplierSearchText(barcode);
|
|
setSupplierDropdownOpen(true);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Barcode scan modal for item */}
|
|
<BarcodeScanModal
|
|
open={itemScanOpen}
|
|
onOpenChange={setItemScanOpen}
|
|
targetField="발주 품목"
|
|
autoSubmit
|
|
onScanSuccess={(barcode) => {
|
|
setItemScanOpen(false);
|
|
// 스캔 결과로 품목 필터
|
|
setKeyword(barcode);
|
|
fetchOrders(barcode);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|