feat(출고요청): 카드 안에서 수량 입력/조절 + 카드/리스트 토글 + 거래명세표 라인 sync 버그 수정
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:
chpark
2026-05-08 10:02:14 +09:00
parent 2f4c5d5d02
commit a5fd64da62
2 changed files with 262 additions and 58 deletions
+1 -1
View File
@@ -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}
+261 -57
View File
@@ -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>
);
}