// === Products list with inline price edit === function ProductsScreen({ products, setProducts, goto, openDetail, productsSource, productsLoading, productsError, productsMeta, reloadProducts }) { const { CATEGORIES } = window.MOCK; const isLive = productsSource === "live"; const [search, setSearch] = useState(""); const [activeCat, setActiveCat] = useState("all"); const [activeStatus, setActiveStatus] = useState("all"); const [selected, setSelected] = useState(new Set()); const [edits, setEdits] = useState({}); // pid -> new salePrice (number) const [discountEdits, setDiscountEdits] = useState({}); // pid -> new immediateDiscount (number) const [confirmSave, setConfirmSave] = useState(false); const [naverSaving, setNaverSaving] = useState(false); const [saveProgress, setSaveProgress] = useState(null); // { done, total, succeeded:[], failed:[] } const filtered = useMemo(() => { return products.filter(p => { if (activeCat !== "all" && p.category !== activeCat) return false; if (activeStatus !== "all" && p.status !== activeStatus) return false; if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !p.sku.toLowerCase().includes(search.toLowerCase())) return false; return true; }); }, [products, search, activeCat, activeStatus]); const toggleAll = () => { if (selected.size === filtered.length) setSelected(new Set()); else setSelected(new Set(filtered.map(p => p.id))); }; const toggleOne = (id) => { const s = new Set(selected); if (s.has(id)) s.delete(id); else s.add(id); setSelected(s); }; // 어떤 상품이라도 dirty(가격 또는 즉시할인 변경 대기)인지 카운트 const dirtyPids = useMemo(() => { const s = new Set(); Object.keys(edits).forEach(k => s.add(k)); Object.keys(discountEdits).forEach(k => s.add(k)); return s; }, [edits, discountEdits]); const dirtyCount = dirtyPids.size; // 변경 대기 목록 (실제로 가격 또는 즉시할인 값이 다른 것만) const dirtyList = useMemo(() => { const out = []; dirtyPids.forEach(pid => { const p = products.find(x => x.id === pid); if (!p) return; const oldPrice = p.price || 0; const oldDiscount = (p.price || 0) - (p.discountedPrice || p.price || 0); // edits/discountEdits 값이 있으면 사용, 없으면 기존 값 유지 const priceRaw = edits[pid]; const discountRaw = discountEdits[pid]; const newPrice = priceRaw !== undefined ? parseInt(String(priceRaw).replace(/[^0-9]/g, ""), 10) : oldPrice; const newDiscount = discountRaw !== undefined ? parseInt(String(discountRaw).replace(/[^0-9]/g, ""), 10) : oldDiscount; const priceChanged = !isNaN(newPrice) && newPrice >= 0 && newPrice !== oldPrice; const discountChanged = !isNaN(newDiscount) && newDiscount >= 0 && newDiscount !== oldDiscount; if (!priceChanged && !discountChanged) return; const newDiscountedPrice = Math.max(0, newPrice - newDiscount); out.push({ pid, name: p.name || pid, oldPrice, newPrice, priceChanged, oldDiscount, newDiscount, discountChanged, oldDiscountedPrice: Math.max(0, oldPrice - oldDiscount), newDiscountedPrice, invalidUnit: (priceChanged && newPrice % 10 !== 0) || (discountChanged && newDiscount % 10 !== 0), originProductNo: p.originProductNo, }); }); return out; }, [dirtyPids, edits, discountEdits, products]); const hasInvalidUnits = dirtyList.some(e => e.invalidUnit); const savePrice = (pid) => { const v = parseInt(edits[pid].toString().replace(/[^0-9]/g, ""), 10); if (isNaN(v) || v < 0) { toast("올바른 가격을 입력하세요"); return; } if (isLive) { // 라이브 모드: 10원 단위 정규화 후 edits 유지. 사용자가 "네이버에 적용..."으로 모달 띄움. const normalized = Math.floor(v / 10) * 10; if (normalized !== v) toast(`10원 단위로 자동 조정: ${formatKRW(normalized)}`); setEdits(prev => ({ ...prev, [pid]: normalized })); return; } setProducts(prev => prev.map(p => p.id === pid ? { ...p, price: v } : p)); setEdits(prev => { const n = { ...prev }; delete n[pid]; return n; }); toast("판매가가 저장되었습니다"); }; // 즉시할인 amount 변경 (라이브 모드 전용) const saveDiscount = (pid) => { const raw = discountEdits[pid]; if (raw === undefined) return; const v = parseInt(String(raw).replace(/[^0-9]/g, ""), 10); if (isNaN(v) || v < 0) { toast("올바른 즉시할인 금액을 입력하세요"); return; } const normalized = Math.floor(v / 10) * 10; if (normalized !== v) toast(`즉시할인 10원 단위로 자동 조정: ${formatNum(normalized)}원`); // 판매가보다 큰 할인은 불가 const p = products.find(x => x.id === pid); const priceForCap = (edits[pid] !== undefined ? edits[pid] : p?.price) || 0; if (normalized > priceForCap) { toast(`즉시할인이 판매가(${formatKRW(priceForCap)})를 넘을 수 없습니다`); setDiscountEdits(prev => ({ ...prev, [pid]: priceForCap })); return; } setDiscountEdits(prev => ({ ...prev, [pid]: normalized })); }; const saveAllEdits = () => { if (isLive) { if (dirtyList.length === 0) { toast("변경된 값이 없습니다"); return; } setSaveProgress(null); setConfirmSave(true); return; } setProducts(prev => prev.map(p => { if (edits[p.id] === undefined) return p; const v = parseInt(edits[p.id].toString().replace(/[^0-9]/g, ""), 10); if (isNaN(v) || v < 0) return p; return { ...p, price: v }; })); setEdits({}); toast(`${dirtyCount}개 상품의 판매가가 저장되었습니다`); }; const discardEdits = () => { setEdits({}); setDiscountEdits({}); toast("변경사항이 취소되었습니다"); }; // 라이브 모드 — 모달 확인 후 호출. 순차 PUT (salePrice + immediateDiscount 동시 변경 지원). const saveAllEditsToNaver = async () => { setNaverSaving(true); const succeeded = []; const failed = []; setSaveProgress({ done: 0, total: dirtyList.length, succeeded: [], failed: [] }); for (let i = 0; i < dirtyList.length; i++) { const item = dirtyList[i]; try { if (!item.originProductNo) throw new Error("originProductNo 누락 — 라이브 상품 아님"); const changes = {}; if (item.priceChanged) changes.salePrice = item.newPrice; if (item.discountChanged) changes.immediateDiscount = item.newDiscount; await window.API.naverChangeProductPricing(item.originProductNo, changes); succeeded.push(item); } catch (err) { console.error("[saveToNaver]", item.pid, err); failed.push({ ...item, error: err.message }); } setSaveProgress({ done: i + 1, total: dirtyList.length, succeeded: [...succeeded], failed: [...failed] }); } // 성공한 것만 products에 반영, edits/discountEdits에서 제거 if (succeeded.length > 0) { const okIds = new Set(succeeded.map(x => x.pid)); setProducts(prev => prev.map(p => { if (!okIds.has(p.id)) return p; const item = succeeded.find(x => x.pid === p.id); return { ...p, price: item.newPrice, discountedPrice: item.newDiscountedPrice }; })); setEdits(prev => { const n = { ...prev }; okIds.forEach(id => delete n[id]); return n; }); setDiscountEdits(prev => { const n = { ...prev }; okIds.forEach(id => delete n[id]); return n; }); } setNaverSaving(false); if (failed.length === 0) { toast(`${succeeded.length}개 변경사항이 네이버에 반영되었습니다`); setTimeout(() => setConfirmSave(false), 1500); } else { toast(`${succeeded.length}개 성공, ${failed.length}개 실패 — 모달에서 확인`); } }; return (
{isLive ? `네이버 라이브 ${products.length}개 표시 (전체 ${productsMeta?.totalElements || "?"}개)` : productsLoading ? "네이버에서 불러오는 중..." : productsError ? `네이버 조회 실패 — 샘플 데이터 표시 중` : `샘플 데이터 ${products.length}개 (API 설정에서 자격증명 입력 시 라이브 표시)`}
| 0 && selected.size === filtered.length} onChange={toggleAll} /> | 상품정보 | 카테고리 | 상태 | 재고 | 판매가 | 즉시할인 | 최종가 | 30일 판매 | |
|---|---|---|---|---|---|---|---|---|---|
| toggleOne(p.id)} /> |
openDetail(p.id)}>{p.name}
{p.sku} · {p.brand}
|
{cat?.icon} {cat?.name} | {formatNum(p.stock)} | {/* 판매가 (편집 가능) */}
setEdits(prev => ({ ...prev, [p.id]: e.target.value.replace(/[^0-9]/g, "") }))}
onBlur={() => isDirty && savePrice(p.id)}
onKeyDown={e => {
if (e.key === "Enter") { e.target.blur(); }
if (e.key === "Escape") { setEdits(prev => { const n = { ...prev }; delete n[p.id]; return n; }); }
}}
title="판매가 (정가)"
/>
{!isLive && (
마진 {margin}%
)}
|
{/* 즉시할인 (라이브 편집 가능, mock에서 readonly) */}
{isLive ? ( setDiscountEdits(prev => ({ ...prev, [p.id]: e.target.value.replace(/[^0-9]/g, "") }))} onBlur={() => isDiscountDirty && saveDiscount(p.id)} onKeyDown={e => { if (e.key === "Enter") { e.target.blur(); } if (e.key === "Escape") { setDiscountEdits(prev => { const n = { ...prev }; delete n[p.id]; return n; }); } }} title="즉시할인 (0 = 할인 안 함)" /> ) : ( — )} | {/* 최종가 (자동 계산, 편집 불가) */}
{isLive ? (
<>
{formatKRW(currentDiscountedPrice)}
{rowDirty && (
(미적용)
)}
>
) : (
{formatKRW(p.price)}
)}
|
{p.sales30d}건
{formatKRW(p.sales30d * p.price)}
|
다음 {dirtyList.length}개 상품의 판매가를 네이버 스토어에 즉시 반영합니다.
| 상품명 · 할인가 변동 | 판매가 | 즉시할인 |
|---|---|---|
|
{item.name}
할인가 {formatKRW(item.oldDiscountedPrice)} → {formatKRW(item.newDiscountedPrice)}
|
{item.priceChanged ? (
<>
{formatKRW(item.oldPrice)}
→ {formatKRW(item.newPrice)}
0 ? "var(--success)" : "var(--danger)", fontSize: 11 }}>
{priceDiff > 0 ? "+" : ""}{formatNum(priceDiff)}
>
) : {formatKRW(item.oldPrice)}}
|
{item.discountChanged ? (
<>
{formatNum(item.oldDiscount)}원
→ {formatNum(item.newDiscount)}원
0 ? "var(--success)" : "var(--danger)", fontSize: 11 }}>
{discountDiff > 0 ? "+" : ""}{formatNum(discountDiff)}
>
) : {formatNum(item.oldDiscount)}원}
|