feat(출고요청): 카드 안에서 수량 입력/조절 + 카드/리스트 토글 + 거래명세표 라인 sync 버그 수정
Deploy momo-erp / deploy (push) Successful in 53s
Deploy momo-erp / deploy (push) Successful in 53s
[출고요청 화면 개선 — /m/orders/new] - 카드에 [수량 입력 + 담기] 한 번에. 엔터 또는 버튼 클릭 시 그 수량만큼 카트에 추가 - 이미 담은 품목은 카드 안에 [- 1 +] 컨트롤 + [×] 빼기 버튼이 즉시 노출 · 카트 수량 그 자리에서 직접 수정. 카드 외 카트 펼치기 불필요 - 담은 품목 카드는 emerald 테두리 + 우상단에 "담은 N" 배지로 강조 [보기 모드 토글] - 검색바 우측에 [카드 / 리스트] 토글 - 카드: 기존 그리드 (이미지 위주, 시각적) - 리스트: 표 형태 (품목 많을 때 한눈에) — 행마다 동일 [수량+담기] 컨트롤 [관리자 거래명세표 라인 sync 버그 fix] - /m/admin/orders 에서 [+택배/+용차] 클릭 시 합계만 올라가고 인풋 표시값이 안 바뀌던 문제 - ExtraRow key 를 `OBJID-QTY-UNIT_PRICE-LABEL` 로 변경해 line 변경 시 컴포넌트 강제 재마운트 - useState 초기값이 새 line 값으로 확실히 반영됨 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -547,7 +547,7 @@ function StatementPreview({
|
||||
if (isExtra && editable) {
|
||||
return (
|
||||
<ExtraRow
|
||||
key={it.OBJID}
|
||||
key={`${it.OBJID}-${it.QTY}-${it.UNIT_PRICE}-${it.EXTRA_LABEL ?? ""}`}
|
||||
line={it}
|
||||
displaySeq={displaySeq}
|
||||
editable={editable}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package } from "lucide-react";
|
||||
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Item {
|
||||
@@ -38,6 +38,7 @@ export default function ItemsBrowse() {
|
||||
const [cart, setCart] = useState<CartLine[]>([]);
|
||||
const [extras, setExtras] = useState<ExtraLine[]>([]);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -70,29 +71,39 @@ export default function ItemsBrowse() {
|
||||
}
|
||||
}, [cartNeedsDelivery, hasDeliveryLine]);
|
||||
|
||||
const addToCart = (item: Item) => {
|
||||
const addToCart = (item: Item) => addManyToCart(item, 1);
|
||||
|
||||
const addManyToCart = (item: Item, qty: number) => {
|
||||
const stock = Number(item.STOCK_QTY);
|
||||
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
let toastTitle = "";
|
||||
let warned = false;
|
||||
setCart((c) => {
|
||||
const found = c.find((x) => x.item.OBJID === item.OBJID);
|
||||
const stock = Number(item.STOCK_QTY);
|
||||
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
if (found) {
|
||||
if (found.qty + 1 > limit) {
|
||||
Swal.fire({
|
||||
icon: "warning",
|
||||
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
|
||||
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}개` : `재고는 ${fmt(stock)}개`}입니다.`,
|
||||
});
|
||||
return c;
|
||||
}
|
||||
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
|
||||
const newQty = (found?.qty ?? 0) + qty;
|
||||
if (newQty > limit) {
|
||||
warned = true;
|
||||
return c;
|
||||
}
|
||||
return [...c, { item, qty: 1 }];
|
||||
toastTitle = found
|
||||
? `수량 +${qty} → ${newQty}개`
|
||||
: `장바구니에 추가됨: ${item.ITEM_NAME} (${qty}개)`;
|
||||
if (found) return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: newQty } : x);
|
||||
return [...c, { item, qty }];
|
||||
});
|
||||
if (warned) {
|
||||
Swal.fire({
|
||||
icon: "warning",
|
||||
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
|
||||
text: `최대 ${fmt(limit)}개까지 담을 수 있습니다.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Swal.fire({
|
||||
toast: true, position: "top-end", icon: "success",
|
||||
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
|
||||
showConfirmButton: false, timer: 1200, timerProgressBar: true,
|
||||
title: toastTitle,
|
||||
showConfirmButton: false, timer: 1000, timerProgressBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -404,8 +415,8 @@ export default function ItemsBrowse() {
|
||||
<p className="text-slate-500 text-xs sm:text-sm mt-1">현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative flex-1">
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={keyword}
|
||||
@@ -423,54 +434,150 @@ export default function ItemsBrowse() {
|
||||
<button onClick={fetchItems} className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
|
||||
조회
|
||||
</button>
|
||||
{/* 보기 모드 토글 */}
|
||||
<div className="flex items-center bg-slate-100 rounded-lg p-0.5 ml-auto">
|
||||
<button
|
||||
onClick={() => setViewMode("card")}
|
||||
title="카드 보기"
|
||||
className={`h-9 px-2.5 rounded-md text-xs font-bold inline-flex items-center gap-1 ${viewMode === "card" ? "bg-white text-emerald-700 shadow" : "text-slate-500"}`}
|
||||
>
|
||||
<LayoutGrid size={14} /> 카드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
title="리스트 보기"
|
||||
className={`h-9 px-2.5 rounded-md text-xs font-bold inline-flex items-center gap-1 ${viewMode === "list" ? "bg-white text-emerald-700 shadow" : "text-slate-500"}`}
|
||||
>
|
||||
<ListIcon size={14} /> 리스트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-slate-400 text-center py-12">불러오는 중...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-12 bg-white rounded-xl border border-slate-100">검색 결과가 없습니다.</div>
|
||||
) : viewMode === "list" ? (
|
||||
<ListView
|
||||
items={items}
|
||||
cart={cart}
|
||||
onAdd={addManyToCart}
|
||||
onPlus={(o) => updateQty(o, 1)}
|
||||
onMinus={(o) => updateQty(o, -1)}
|
||||
onSetQty={setQty}
|
||||
onRemove={removeLine}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 xl:grid-cols-3 gap-2 sm:gap-3">
|
||||
{items.map((it) => (
|
||||
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition">
|
||||
<div className="aspect-square bg-slate-50 rounded-lg mb-2 sm:mb-3 overflow-hidden flex items-center justify-center">
|
||||
{it.IMAGE_URL ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
|
||||
{items.map((it) => {
|
||||
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
|
||||
const inCart = cartLine?.qty ?? 0;
|
||||
const stock = Number(it.STOCK_QTY);
|
||||
const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
const soldOut = stock === 0;
|
||||
return (
|
||||
<div key={it.OBJID} className={`bg-white border rounded-xl p-3 sm:p-4 transition ${inCart > 0 ? "border-emerald-400 ring-2 ring-emerald-100" : "border-slate-200 hover:shadow-md"}`}>
|
||||
<div className="aspect-square bg-slate-50 rounded-lg mb-2 sm:mb-3 overflow-hidden flex items-center justify-center relative">
|
||||
{it.IMAGE_URL ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="text-slate-300 text-xs">이미지 없음</div>
|
||||
)}
|
||||
{inCart > 0 && (
|
||||
<span className="absolute top-1 right-1 bg-emerald-600 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full">
|
||||
담은 {inCart}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<div className="font-bold text-xs sm:text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
|
||||
<div className="flex flex-col gap-0.5 items-end shrink-0">
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mb-1.5 truncate">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<div className="font-bold text-slate-900 tabular-nums text-sm">₩{fmt(it.UNIT_PRICE)}</div>
|
||||
<div className={`text-[11px] font-semibold ${stock > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
재고 {fmt(stock)}{it.UNIT}
|
||||
</div>
|
||||
</div>
|
||||
{maxQ > 0 && (
|
||||
<div className="text-[10px] text-sky-700 mb-1">1회 한도 ≤ {fmt(maxQ)}</div>
|
||||
)}
|
||||
|
||||
{/* 수량 컨트롤 — 카드 안에서 바로 조절 */}
|
||||
{soldOut ? (
|
||||
<div className="w-full mt-1.5 h-9 rounded-lg bg-slate-100 text-slate-400 text-xs font-bold flex items-center justify-center">
|
||||
품절
|
||||
</div>
|
||||
) : inCart === 0 ? (
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={limit}
|
||||
defaultValue={1}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const val = Number((e.target as HTMLInputElement).value) || 1;
|
||||
const q = Math.max(1, Math.min(limit, val));
|
||||
addManyToCart(it, q);
|
||||
(e.target as HTMLInputElement).value = "1";
|
||||
}
|
||||
}}
|
||||
id={`qty-${it.OBJID}`}
|
||||
className="w-12 h-9 px-2 rounded-lg border border-slate-200 text-center text-sm tabular-nums focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById(`qty-${it.OBJID}`) as HTMLInputElement | null;
|
||||
const val = el ? Number(el.value) || 1 : 1;
|
||||
const q = Math.max(1, Math.min(limit, val));
|
||||
addManyToCart(it, q);
|
||||
if (el) el.value = "1";
|
||||
}}
|
||||
className="flex-1 h-9 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 flex items-center justify-center gap-1"
|
||||
>
|
||||
<Plus size={13} /> 담기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-300 text-xs">이미지 없음</div>
|
||||
<div className="flex items-center gap-1 mt-1.5 bg-emerald-50 border border-emerald-200 rounded-lg p-1">
|
||||
<button onClick={() => updateQty(it.OBJID, -1)}
|
||||
className="w-8 h-8 rounded-md bg-white border border-emerald-200 hover:bg-emerald-100 flex items-center justify-center">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={limit}
|
||||
value={inCart}
|
||||
onChange={(e) => setQty(it.OBJID, Number(e.target.value))}
|
||||
className="flex-1 h-8 text-center font-bold text-sm tabular-nums bg-white border border-emerald-200 rounded-md"
|
||||
/>
|
||||
<button onClick={() => updateQty(it.OBJID, 1)}
|
||||
className="w-8 h-8 rounded-md bg-white border border-emerald-200 hover:bg-emerald-100 flex items-center justify-center">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button onClick={() => removeLine(it.OBJID)}
|
||||
title="빼기"
|
||||
className="w-8 h-8 rounded-md bg-white border border-rose-200 hover:bg-rose-100 text-rose-500 flex items-center justify-center">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<div className="font-bold text-xs sm:text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
|
||||
<div className="flex flex-col gap-0.5 items-end shrink-0">
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mb-1.5 truncate">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<div className="font-bold text-slate-900 tabular-nums text-sm">₩{fmt(it.UNIT_PRICE)}</div>
|
||||
<div className={`text-[11px] font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
재고 {fmt(it.STOCK_QTY)}{it.UNIT}
|
||||
</div>
|
||||
</div>
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<div className="text-[10px] text-sky-700 mb-1">1회 한도 ≤ {fmt(it.MAX_ORDER_QTY)}</div>
|
||||
)}
|
||||
<button
|
||||
disabled={Number(it.STOCK_QTY) === 0}
|
||||
onClick={() => addToCart(it)}
|
||||
className="w-full mt-1.5 h-8 sm:h-9 rounded-lg bg-emerald-700 text-white text-[11px] sm:text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
<Plus size={13} /> 담기
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -491,3 +598,100 @@ function Row({ label, value, color }: { label: string; value: string; color?: "v
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({ items, cart, onAdd, onPlus, onMinus, onSetQty, onRemove }: {
|
||||
items: Item[]; cart: CartLine[];
|
||||
onAdd: (it: Item, qty: number) => void;
|
||||
onPlus: (o: string) => void;
|
||||
onMinus: (o: string) => void;
|
||||
onSetQty: (o: string, v: number) => void;
|
||||
onRemove: (o: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2.5">품목</th>
|
||||
<th className="text-center px-3 py-2.5 w-14">구분</th>
|
||||
<th className="text-right px-3 py-2.5 w-20">단가</th>
|
||||
<th className="text-right px-3 py-2.5 w-16">재고</th>
|
||||
<th className="text-center px-3 py-2.5 w-[180px]">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => {
|
||||
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
|
||||
const inCart = cartLine?.qty ?? 0;
|
||||
const stock = Number(it.STOCK_QTY);
|
||||
const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
const soldOut = stock === 0;
|
||||
return (
|
||||
<tr key={it.OBJID} className={`border-t border-slate-100 ${inCart > 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-semibold">
|
||||
{it.ITEM_NAME}
|
||||
{it.REQUIRES_DELIVERY === "Y" && <span className="ml-1 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold">택배</span>}
|
||||
{inCart > 0 && <span className="ml-1 px-1.5 py-0.5 rounded bg-emerald-600 text-white text-[10px] font-bold">담은 {inCart}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400">{it.MAKER_NAME || "-"}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${it.IS_TAX_FREE === "Y" ? "bg-violet-100 text-violet-700" : "bg-rose-100 text-rose-700"}`}>
|
||||
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-bold">₩{Number(it.UNIT_PRICE).toLocaleString("ko-KR")}</td>
|
||||
<td className={`px-3 py-2 text-right tabular-nums ${stock <= 0 ? "text-rose-500 font-bold" : "text-slate-700"}`}>{Number(stock).toLocaleString("ko-KR")}</td>
|
||||
<td className="px-3 py-2">
|
||||
{soldOut ? (
|
||||
<div className="text-center text-xs text-slate-400">품절</div>
|
||||
) : inCart === 0 ? (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={limit}
|
||||
defaultValue={1}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const v = Number((e.target as HTMLInputElement).value) || 1;
|
||||
onAdd(it, Math.max(1, Math.min(limit, v)));
|
||||
(e.target as HTMLInputElement).value = "1";
|
||||
}
|
||||
}}
|
||||
id={`lqty-${it.OBJID}`}
|
||||
className="w-14 h-8 px-2 rounded border border-slate-200 text-center text-sm tabular-nums"
|
||||
/>
|
||||
<button onClick={() => {
|
||||
const el = document.getElementById(`lqty-${it.OBJID}`) as HTMLInputElement | null;
|
||||
const v = el ? Number(el.value) || 1 : 1;
|
||||
onAdd(it, Math.max(1, Math.min(limit, v)));
|
||||
if (el) el.value = "1";
|
||||
}} className="h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 inline-flex items-center gap-0.5">
|
||||
<Plus size={12} /> 담기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button onClick={() => onMinus(it.OBJID)} className="w-8 h-8 rounded bg-white border border-emerald-200 hover:bg-emerald-100 flex items-center justify-center"><Minus size={12} /></button>
|
||||
<input type="number" min={1} max={limit} value={inCart}
|
||||
onChange={(e) => onSetQty(it.OBJID, Number(e.target.value))}
|
||||
className="w-12 h-8 text-center font-bold text-sm tabular-nums border border-emerald-200 rounded" />
|
||||
<button onClick={() => onPlus(it.OBJID)} className="w-8 h-8 rounded bg-white border border-emerald-200 hover:bg-emerald-100 flex items-center justify-center"><Plus size={12} /></button>
|
||||
<button onClick={() => onRemove(it.OBJID)} title="빼기" className="w-8 h-8 rounded bg-white border border-rose-200 hover:bg-rose-100 text-rose-500 flex items-center justify-center"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user