// === 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 설정에서 자격증명 입력 시 라이브 표시)`}

{isLive && reloadProducts && ( )}
{isLive && dirtyCount === 0 && (
가격 칸을 클릭해 수정 후 "네이버에 적용..." 버튼을 누르면 확인 모달이 뜹니다. 한 번에 여러 상품을 모아서 일괄 적용할 수 있어요.
)} {productsError && (
네이버 조회 실패: {productsError} {reloadProducts && ( )}
)} {/* Toolbar */}
setSearch(e.target.value)} />
{CATEGORIES.map(c => ( ))}
{/* Selection / dirty bar */} {(selected.size > 0 || dirtyCount > 0) && (
{selected.size > 0 && ( <> {selected.size}개 선택됨 )} {dirtyCount > 0 && ( <> 0 ? 12 : 0, fontSize: 13, fontWeight: 500 }}> {dirtyCount}개 항목 {isLive ? "네이버 적용 대기 (판매가/즉시할인)" : "변경 대기"}
)}
)} {/* Table */}
{filtered.map(p => { const cat = CATEGORIES.find(c => c.id === p.category); const margin = ((p.price - p.costPrice) / p.price * 100).toFixed(1); const isDirty = edits[p.id] !== undefined; const isDiscountDirty = discountEdits[p.id] !== undefined; // 현재 표시할 값: dirty면 edits 값, 아니면 product 값 const currentPrice = isDirty ? edits[p.id] : p.price; const currentDiscount = isDiscountDirty ? discountEdits[p.id] : (p.price - (p.discountedPrice || p.price)); const currentDiscountedPrice = Math.max(0, (parseInt(currentPrice) || 0) - (parseInt(currentDiscount) || 0)); const rowDirty = isDirty || isDiscountDirty; return ( {/* 판매가 (편집 가능) */} {/* 즉시할인 (라이브 편집 가능, mock에서 readonly) */} {/* 최종가 (자동 계산, 편집 불가) */} ); })}
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}%
)}
{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)}
{filtered.length === 0 && (
검색 결과가 없습니다
)}
{/* 네이버 가격 적용 확인 모달 (라이브 모드 전용) */} { if (!naverSaving) { setConfirmSave(false); setSaveProgress(null); } }} title="네이버에 가격 변경 적용" size="lg" footer={ !saveProgress ? ( <> ) : naverSaving ? ( ) : ( ) }> {!saveProgress ? ( <>

다음 {dirtyList.length}개 상품의 판매가를 네이버 스토어에 즉시 반영합니다.

{dirtyList.map(item => { const priceDiff = item.newPrice - item.oldPrice; const discountDiff = item.newDiscount - item.oldDiscount; return ( ); })}
상품명 · 할인가 변동 판매가 즉시할인
{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)}원}
주의: 적용은 즉시 네이버 스토어에 반영됩니다. 되돌리려면 다시 가격을 변경해야 합니다. (참고: 첫 PUT 시 상품의 detailContent 일부 메타데이터가 sanitize됩니다 — 이미지/텍스트 표시는 정상 유지)
) : ( <>
{naverSaving ? "네이버에 적용 중..." : "완료"} {saveProgress.done} / {saveProgress.total}
{saveProgress.succeeded.length} 성공 {saveProgress.failed.length} 실패
{saveProgress.failed.length > 0 && (
실패 목록 (다시 시도 가능):
{saveProgress.failed.map(f => (
{f.name}
{f.error}
))}
)} )}
); } window.ProductsScreen = ProductsScreen;