// === Bulk price edit === function BulkEditScreen({ products, setProducts, productsSource }) { const { CATEGORIES } = window.MOCK; const isLive = productsSource === "live"; const [keyword, setKeyword] = useState(""); const [selectedCats, setSelectedCats] = useState(new Set()); 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(); products.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")); }, [products, isLive]); const matched = useMemo(() => { return products.filter(p => { if (keyword && !p.name.toLowerCase().includes(keyword.toLowerCase())) return false; if (selectedCats.size > 0 && !selectedCats.has(p.category)) return false; return true; }); }, [products, keyword, selectedCats]); // 가격 계산 — 네이버는 10원 단위만 받음 const calcNewPrice = (oldPrice) => { let np; if (mode === "set") np = value; else { const sign = direction === "up" ? 1 : -1; if (mode === "percent") np = oldPrice * (1 + sign * value / 100); else np = oldPrice + sign * value; } np = Math.max(0, Math.round(np / 10) * 10); // 10원 단위 내림 반올림 return np; }; // 즉시할인은 절대값이라 판매가 변경해도 그대로 유지 → 할인가 재계산 const previewRows = matched.map(p => { const newPrice = calcNewPrice(p.price); const oldDp = p.discountedPrice || p.price; const discount = p.price - oldDp; const newDiscountedPrice = Math.max(0, newPrice - discount); return { ...p, newPrice, oldDiscountedPrice: oldDp, newDiscountedPrice, discount }; }).filter(p => p.newPrice !== p.price); const totalDelta = previewRows.reduce((s, p) => s + (p.newPrice - p.price), 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 }; })); toast(`${previewRows.length}개 상품의 판매가가 수정되었습니다`); setPreviewOpen(false); return; } // 라이브 모드: 순차 PUT 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 누락"); await window.API.naverChangePrice(item.originProductNo, item.newPrice); 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에 반영 (할인가도 재계산) if (succeeded.length > 0) { const okIds = new Set(succeeded.map(x => x.id)); setProducts(prev => prev.map(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 }; })); } 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 ? `네이버 라이브 ${products.length}개 상품에서 필터 → 미리보기 → 일괄 적용 (10원 단위 반올림)` : "키워드와 카테고리로 상품을 필터링한 뒤 일괄로 가격을 변경합니다"}

{/* Left: rule builder */}
1. 대상 상품 필터
setKeyword(e.target.value)} />
{effectiveCategories.map(c => ( ))}
선택 안 함 = 전체 카테고리
2. 변경 방식
{mode !== "set" && (
)}
setValue(parseInt(e.target.value) || 0)} /> {mode === "percent" && (
{[5, 10, 15].map(v => ( ))}
)}
요약: {mode === "set" ? `모든 상품을 ${formatKRW(value)}로 통일` : mode === "percent" ? `현재가의 ${value}% ${direction === "up" ? "인상" : "인하"} (10원 단위 반올림)` : `${formatKRW(value)} ${direction === "up" ? "인상" : "인하"}`}
{/* Right: matched preview */}
매칭된 상품 ({matched.length})
총 변동액: = 0 ? "var(--accent)" : "var(--danger)" }}> {totalDelta >= 0 ? "+" : ""}{formatKRW(totalDelta)}
{previewRows.length === 0 ? (
필터 조건에 맞는 상품이 없습니다
) : ( {previewRows.map(p => { const diff = p.newPrice - p.price; const hasDiscount = p.discount > 0; return ( ); })}
상품 현재가 변경가 차액
{p.name}
{hasDiscount && (
할인가 {formatKRW(p.oldDiscountedPrice)} → {formatKRW(p.newDiscountedPrice)}
)}
{formatKRW(p.price)} {formatKRW(p.newPrice)} = 0 ? "var(--accent)" : "var(--danger)" }}> {diff >= 0 ? "+" : ""}{formatKRW(diff)}
)}
{/* Confirm modal */} ) : naverSaving ? ( ) : ( ) }> {!saveProgress ? ( <>
{previewRows.length}개 상품의 가격이 변경됩니다 (실제 변경분만 표시 · 10원 단위 반올림)
총 변동액: = 0 ? "var(--accent)" : "var(--danger)" }}> {totalDelta >= 0 ? "+" : ""}{formatKRW(totalDelta)}
{isLive ? <>주의: 적용은 즉시 네이버 스토어에 반영됩니다. 순차 처리되며 중간에 "중지" 가능하지만 이미 적용된 항목은 자동 원복되지 않습니다. (10원 단위 정규화) : "이 작업은 되돌릴 수 없습니다. 변경 후에도 30일간 가격 변경 이력에서 확인 가능."}
{previewRows.length > 0 && (
{previewRows.slice(0, 50).map(p => { const diff = p.newPrice - p.price; const hasDiscount = p.discount > 0; return ( ); })} {previewRows.length > 50 && ( )}
상품 현재가 변경가 차액
{p.name} {hasDiscount && (
할인가 {formatKRW(p.oldDiscountedPrice)} → {formatKRW(p.newDiscountedPrice)} (즉시할인 {formatNum(p.discount)}원 유지)
)}
{formatKRW(p.price)} {formatKRW(p.newPrice)} = 0 ? "var(--accent)" : "var(--danger)", fontSize: 12 }}> {diff >= 0 ? "+" : ""}{formatKRW(diff)}
... 외 {previewRows.length - 50}개 (전체 적용됩니다)
)} ) : ( <>
{naverSaving ? "네이버에 적용 중..." : saveProgress.aborted ? "중지됨" : "완료"} {saveProgress.done} / {saveProgress.total}
{saveProgress.succeeded.length} 성공 {saveProgress.failed.length} 실패 {saveProgress.aborted && · 사용자 중지}
{saveProgress.failed.length > 0 && (
실패 목록:
{saveProgress.failed.map(f => (
{f.name}
{f.error}
))}
)} )}
); } window.BulkEditScreen = BulkEditScreen;