// === Products list with inline price edit === // 라이브 모드: 검색/필터는 모두 클라이언트 측 처리 (allProducts 전체 로드 후 로컬 필터). // 네이버 검색 API가 searchKeyword 필드를 무시하는 이슈 때문 (productName/searchKeywordType 둘 다 실패). // 페이지네이션도 클라이언트 측. function ProductsScreen({ products, setProducts, goto, openDetail, productsSource, productsLoading, productsError, productsMeta, productsPage, productsSearch, reloadProducts, allProducts, setAllProducts, allProductsLoading, allProductsProgress, loadAllProducts }) { 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 [page, setPage] = useState(1); const PAGE_SIZE = 100; // 사용자 메타데이터 (매입처, 모델) — Supabase 또는 localStorage. 페이지 진입 시 로드. const [metaMap, setMetaMap] = useState(new Map()); // originProductNo → {supplier, model, ...} const [metaEdits, setMetaEdits] = useState({}); // pid → { supplier?, model? } 변경 대기 useEffect(() => { if (!window.MetaStore || sourceList.length === 0) return; const ids = sourceList.map(p => p.originProductNo).filter(Boolean).map(String); if (ids.length === 0) return; window.MetaStore.getMany(ids).then(m => setMetaMap(m)).catch(err => console.warn("[meta load]", err)); }, [sourceList]); const saveMeta = async (pid, field, value) => { const p = sourceList.find(x => x.id === pid); const opn = p?.originProductNo; if (!opn) return; try { const updated = await window.MetaStore.set(String(opn), { [field]: value }); setMetaMap(prev => { const n = new Map(prev); n.set(String(opn), updated); return n; }); setMetaEdits(prev => { const n = { ...prev }; if (n[pid]) { delete n[pid][field]; if (Object.keys(n[pid]).length === 0) delete n[pid]; } return n; }); } catch (err) { toast("저장 실패: " + err.message); } }; const setMetaEdit = (pid, field, value) => { setMetaEdits(prev => ({ ...prev, [pid]: { ...(prev[pid] || {}), [field]: value } })); }; // 라이브 모드: 전체 상품 자동 로드. 일괄과 동일 패턴. useEffect(() => { if (!isLive) return; if (allProducts) return; if (allProductsLoading) return; if (!loadAllProducts) return; const totalInStore = productsMeta?.totalElements || 0; if (totalInStore && totalInStore <= products.length) return; // 이미 다 들어있음 loadAllProducts().catch(err => { console.error("[products auto-load all]", err); toast("전체 상품 로드 실패: " + err.message); }); }, [isLive, allProducts, allProductsLoading]); // 데이터 소스 선택: 라이브 + 전체 로드되면 전체, 아니면 페이지된 products (또는 mock) const sourceList = (isLive && allProducts) ? allProducts : products; const isUsingFullList = isLive && !!allProducts; // 클라이언트 측 검색/필터 const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return sourceList.filter(p => { if (activeCat !== "all" && p.category !== activeCat) return false; if (activeStatus !== "all" && p.status !== activeStatus) return false; if (q) { const name = (p.name || "").toLowerCase(); const sku = (p.sku || "").toLowerCase(); if (!name.includes(q) && !sku.includes(q)) return false; } return true; }); }, [sourceList, search, activeCat, activeStatus]); // 검색/필터 바뀌면 페이지 리셋 useEffect(() => { setPage(1); }, [search, activeCat, activeStatus]); // 페이지네이션 (클라이언트) const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * PAGE_SIZE; const pageItems = filtered.slice(pageStart, pageStart + PAGE_SIZE); // 검색창 입력 핸들러 — 즉시 (디바운스 불필요, 로컬 필터라서) const handleSearchChange = (e) => setSearch(e.target.value); const clearSearch = () => setSearch(""); const toggleAll = () => { // 현재 페이지 전체 선택/해제 (전체 필터된 결과 아님 — UX 혼란 방지) const pageIds = new Set(pageItems.map(p => p.id)); const allOnPageSelected = pageItems.every(p => selected.has(p.id)); if (allOnPageSelected) { const n = new Set(selected); pageIds.forEach(id => n.delete(id)); setSelected(n); } else { const n = new Set(selected); pageIds.forEach(id => n.add(id)); setSelected(n); } }; 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 = sourceList.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, sourceList]); 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 + allProducts(전체 캐시)에 반영 if (succeeded.length > 0) { const okIds = new Set(succeeded.map(x => x.pid)); const applyUpdate = (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 }; }; setProducts(prev => prev.map(applyUpdate)); if (setAllProducts && allProducts) { setAllProducts(prev => prev ? prev.map(applyUpdate) : prev); } 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 ? (allProductsLoading ? `전체 상품 불러오는 중... ${allProductsProgress?.done || 0}/${allProductsProgress?.total || "?"}` : <> 네이버 라이브 전체 {sourceList.length}개 {(search || activeCat !== "all" || activeStatus !== "all") && ( <> · 필터 결과 {filtered.length}개> )} {!isUsingFullList && productsMeta && ( <> · 페이지된 {products.length}개만 표시 (전체 로드 중)> )} >) : productsLoading ? "네이버에서 불러오는 중..." : productsError ? `네이버 조회 실패 — 샘플 데이터 표시 중` : `샘플 데이터 ${products.length}개 (API 설정에서 자격증명 입력 시 라이브 표시)`}
| 0 && pageItems.every(p => selected.has(p.id))} onChange={toggleAll} title="현재 페이지 전체 선택/해제" /> | 상품정보 | 매입처 | Model | 재고 | 판매가 | 즉시할인 | 최종가 | 30일 판매 | ||
|---|---|---|---|---|---|---|---|---|---|---|
| toggleOne(p.id)} /> |
openDetail(p.id)}>{p.name}
{p.sku} · {p.brand}
|
{/* 상태 — 색깔 dot (호버 시 라벨) */}
{/* 매입처 — 값 있으면 진하게, 없으면 placeholder가 연하게 */} | setMetaEdit(p.id, "supplier", e.target.value)} onBlur={() => supplierEdit !== undefined && saveMeta(p.id, "supplier", supplierEdit)} onKeyDown={e => { if (e.key === "Enter") e.target.blur(); if (e.key === "Escape") setMetaEdits(prev => { const n = { ...prev }; if (n[p.id]) { delete n[p.id].supplier; if (Object.keys(n[p.id]).length === 0) delete n[p.id]; } return n; }); }} placeholder="매입처" title="매입처" /> | {/* Model — 값 있으면 진하게, 없으면 placeholder 연하게 */}setMetaEdit(p.id, "model", e.target.value)} onBlur={() => modelEdit !== undefined && saveMeta(p.id, "model", modelEdit)} onKeyDown={e => { if (e.key === "Enter") e.target.blur(); if (e.key === "Escape") setMetaEdits(prev => { const n = { ...prev }; if (n[p.id]) { delete n[p.id].model; if (Object.keys(n[p.id]).length === 0) delete n[p.id]; } return n; }); }} placeholder="모델" title="모델명 (사용자 메모)" /> | {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}%
)}
|
{/* 즉시할인 — 폭 20% 축소 (input 자체 너비 제한) */}
{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)}원}
|