// === Bulk price edit === function BulkEditScreen({ products, setProducts, productsSource, allProducts, setAllProducts, allProductsLoading, allProductsProgress, loadAllProducts, productsMeta }) { const { CATEGORIES } = window.MOCK; const isLive = productsSource === "live"; // 라이브 모드에서는 페이지된 100개가 아니라 전체 상품(allProducts) 사용. // 아직 안 불러왔으면 자동으로 한 번 페치. const totalInStore = productsMeta?.totalElements || products.length; const workingSet = isLive ? (allProducts || products) : products; const isPartial = isLive && !allProducts && totalInStore > products.length; useEffect(() => { if (!isLive) return; if (allProducts) return; // 이미 로드됨 if (allProductsLoading) return; // 로딩 중 if (!loadAllProducts) return; // 전체 상품이 페이지 1개에 다 들어가면 굳이 페이징 안 함 (이미 products에 다 있음) if (totalInStore <= products.length) return; loadAllProducts().catch(err => { console.error("[bulk loadAllProducts]", err); toast("전체 상품 로드 실패: " + err.message); }); }, [isLive, allProducts, allProductsLoading, totalInStore]); const [keyword, setKeyword] = useState(""); const [selectedCats, setSelectedCats] = useState(new Set()); const [target, setTarget] = useState("salePrice"); // "salePrice" | "discount" const [mode, setMode] = useState("percent"); // percent / fixed / set const [direction, setDirection] = useState("up"); const [value, setValue] = useState(10); const [previewOpen, setPreviewOpen] = useState(false); const [naverSaving, setNaverSaving] = useState(false); const [saveProgress, setSaveProgress] = useState(null); const abortRef = useRef(false); // 라이브 모드면 카테고리 chip을 실제 상품 데이터에서 동적 생성 (전체 상품 기준) const effectiveCategories = useMemo(() => { if (!isLive) return CATEGORIES; const seen = new Map(); workingSet.forEach(p => { if (p.category && !seen.has(p.category)) { seen.set(p.category, { id: p.category, name: p.category, icon: "" }); } }); return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name, "ko")); }, [workingSet, isLive]); const matched = useMemo(() => { return workingSet.filter(p => { if (keyword && !p.name.toLowerCase().includes(keyword.toLowerCase())) return false; if (selectedCats.size > 0 && !selectedCats.has(p.category)) return false; return true; }); }, [workingSet, keyword, selectedCats]); // 일반화된 계산 — 판매가/즉시할인 양쪽에 사용. 네이버는 10원 단위만 받음. const calcNew = (oldValue) => { let nv; if (mode === "set") nv = value; else { const sign = direction === "up" ? 1 : -1; if (mode === "percent") nv = oldValue * (1 + sign * value / 100); else nv = oldValue + sign * value; } nv = Math.max(0, Math.round(nv / 10) * 10); // 10원 단위 반올림 return nv; }; // 미리보기 행: target에 따라 다른 필드를 계산 const previewRows = matched.map(p => { const oldPrice = p.price; const oldDiscount = oldPrice - (p.discountedPrice || oldPrice); if (target === "salePrice") { // 판매가 변경, 즉시할인은 유지 → 할인가는 자동 변동 const newPrice = calcNew(oldPrice); const newDiscountedPrice = Math.max(0, newPrice - oldDiscount); return { ...p, oldPrice, newPrice, oldDiscount, newDiscount: oldDiscount, oldDiscountedPrice: p.discountedPrice || oldPrice, newDiscountedPrice, priceChanged: newPrice !== oldPrice, discountChanged: false, }; } // discount 변경, 판매가는 유지 → 할인가도 함께 변동 const newDiscount = Math.min(calcNew(oldDiscount), oldPrice); // 할인은 판매가 초과 불가 const newDiscountedPrice = Math.max(0, oldPrice - newDiscount); return { ...p, oldPrice, newPrice: oldPrice, oldDiscount, newDiscount, oldDiscountedPrice: p.discountedPrice || oldPrice, newDiscountedPrice, priceChanged: false, discountChanged: newDiscount !== oldDiscount, }; }).filter(p => p.priceChanged || p.discountChanged); const totalDelta = previewRows.reduce((s, p) => { // 매출 영향: 할인가 변동분 (실판매가) 기준 return s + (p.newDiscountedPrice - p.oldDiscountedPrice); }, 0); const toggleCat = (id) => { const s = new Set(selectedCats); if (s.has(id)) s.delete(id); else s.add(id); setSelectedCats(s); }; const apply = async () => { if (!isLive) { // mock 모드: 즉시 로컬 반영 setProducts(prev => prev.map(p => { const m = previewRows.find(r => r.id === p.id); if (!m) return p; return { ...p, price: m.newPrice, discountedPrice: m.newDiscountedPrice }; })); toast(`${previewRows.length}개 상품 수정 완료`); setPreviewOpen(false); return; } // 라이브 모드: 순차 PUT (target에 따라 salePrice 또는 immediateDiscount 변경) abortRef.current = false; setNaverSaving(true); const succeeded = []; const failed = []; setSaveProgress({ done: 0, total: previewRows.length, succeeded: [], failed: [] }); for (let i = 0; i < previewRows.length; i++) { if (abortRef.current) break; const item = previewRows[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("[bulk-saveToNaver]", item.id, err); failed.push({ ...item, error: err.message }); } setSaveProgress({ done: i + 1, total: previewRows.length, succeeded: [...succeeded], failed: [...failed], aborted: abortRef.current }); } // 성공한 것만 products + allProducts(전체 캐시)에 반영 if (succeeded.length > 0) { const okIds = new Set(succeeded.map(x => x.id)); const applyUpdate = (p) => { if (!okIds.has(p.id)) return p; const item = succeeded.find(x => x.id === p.id); return { ...p, price: item.newPrice, discountedPrice: item.newDiscountedPrice }; }; setProducts(prev => prev.map(applyUpdate)); if (setAllProducts && allProducts) { setAllProducts(prev => prev ? prev.map(applyUpdate) : prev); } } setNaverSaving(false); if (failed.length === 0 && !abortRef.current) { toast(`${succeeded.length}개 가격이 네이버에 반영되었습니다`); } else if (abortRef.current) { toast(`중지됨 — ${succeeded.length}개 적용, ${previewRows.length - succeeded.length - failed.length}개 미적용`); } else { toast(`${succeeded.length}개 성공, ${failed.length}개 실패`); } }; const abortBulk = () => { abortRef.current = true; }; const closePreview = () => { if (naverSaving) return; setPreviewOpen(false); setSaveProgress(null); }; return (
{isLive ? (allProductsLoading ? `네이버 전체 상품 불러오는 중... ${allProductsProgress?.done || 0} / ${allProductsProgress?.total || "?"}개` : allProducts ? `네이버 라이브 전체 ${workingSet.length}개 상품에서 필터 → 미리보기 → 일괄 적용 (10원 단위 반올림)` : `현재 ${products.length}개 표시 (전체 ${totalInStore}개 자동 로드 중)`) : "키워드와 카테고리로 상품을 필터링한 뒤 일괄로 가격을 변경합니다"}
| 상품 | {target === "salePrice" ? "판매가" : "즉시할인"} | 실판매가 |
|---|---|---|
|
{p.name}
|
{target === "salePrice" ? (
<>
{formatKRW(p.oldPrice)}
→ {formatKRW(p.newPrice)}
>
) : (
<>
{formatNum(p.oldDiscount)}원
→ {formatNum(p.newDiscount)}원
>
)}
= 0 ? "var(--accent)" : "var(--danger)", fontSize: 11 }}>
{targetDiff >= 0 ? "+" : ""}{formatNum(targetDiff)}
|
{formatKRW(p.oldDiscountedPrice)}
→ {formatKRW(p.newDiscountedPrice)}
= 0 ? "var(--accent)" : "var(--danger)", fontSize: 11 }}>
{dpDiff >= 0 ? "+" : ""}{formatNum(dpDiff)}
|
| 상품 | 판매가 | 즉시할인 | 실판매가 |
|---|---|---|---|
| {p.name} |
{p.priceChanged ? (
<> {formatKRW(p.oldPrice)} → {formatKRW(p.newPrice)} >
) : {formatKRW(p.oldPrice)}}
|
{p.discountChanged ? (
<> {formatNum(p.oldDiscount)}원 → {formatNum(p.newDiscount)}원 >
) : {formatNum(p.oldDiscount)}원}
|
{formatKRW(p.oldDiscountedPrice)}
→ {formatKRW(p.newDiscountedPrice)}
|
| ... 외 {previewRows.length - 50}개 (전체 적용됩니다) | |||