Panel de Herramientas HarujaGdl

Selecciona una herramienta para empezar.

Registro de nuevas prendas
Genera el código y guarda la prenda en Firestore.
Base de datos códigos HarujaGdl
Consulta códigos, imprime etiquetas y administra en modo admin.
Registrar apartado
Crea apartados, registra abonos y genera ticket PDF.
Ventas & Comisiones
Resumen mensual sincronizado con Tiendanube.
Total del mes
Total por vendedora
Sin asignar
Plan de lealtad
Registra clientes, puntos, compras y canjes.

Registro de nuevas prendas

Selecciona los atributos clave para generar el código y guardar la prenda en Firestore.

Base de datos códigos HarujaGdl
Explora la base de prendas 2025 con búsqueda, filtros y estado de novedades.
Total
Nuevos (7 días)
Existentes
Código Descripción Tipo Color Talla Proveedor Status Disponibilidad Fecha P. Venta (Admin) Costo (Admin) Margen (Admin) Utilidad Acciones
Cargando…
Mostrando 0
Página 1
Registros por página
`; }; const openPrintLabels = (scope = "page") => { const rows = getRowsForScope(scope); if (!safeRows.length) { setPrendasAlert("No hay filas para imprimir en el alcance seleccionado."); return; } const logoSrc = ""; // Nota: el layout objetivo usa el logo como texto (harujagdl) debajo del QR, // así que no intentamos renderizar ^GFA aquí para evitar errores por compresión. const labels = rows.map((row) => { const codigo = safeString(row.codigo || row.code).trim().toUpperCase(); const precioNum = getLabelPriceNumber(row); return { codigo, precioTexto: formatLabelPrice(precioNum), qrValue: codigo, logoSrc }; }); const printWindow = window.open("", "_blank"); if (!printWindow) { setPrendasAlert("No se pudo abrir la ventana de impresión. Revisa bloqueador de popups."); return; } printWindow.document.write(getPrintHtml(labels)); printWindow.document.close(); printWindow.focus(); }; const computeMarginUtility = (pVenta, costo) => { const p = toNumberOrNull(pVenta) ?? 0; const c = toNumberOrNull(costo) ?? 0; const utilidad = Number((p - c).toFixed(2)); const margenPct = p > 0 ? Number(((utilidad / p) * 100).toFixed(1)) : 0; return { utilidad, margenPct }; }; const buildSearchKey = (data) => { const codigo = safeString(data.codigo || data.code || data.Codigo || data.Code); const descripcion = safeString(data.descripcion || data.Descripcion || data.detalles); return normalizeText(`${codigo} ${descripcion}`.trim()); }; const pickFirstValue = (...values) => { for (const value of values) { if (value !== undefined && value !== null && value !== "") { return value; } } return null; }; const normalizeDoc = (docSnap) => { const data = docSnap.data() || {}; const codigo = safeString( data.codigo ?? data.Codigo ?? data.CODIGO ?? data.SKU ?? data.code ?? data.Code ?? docSnap.id ).trim(); const descripcion = safeString( data.descripcion ?? data.Descripcion ?? data.detalles ?? data.Detalles ?? "" ).trim(); const proveedor = safeString(data.proveedor || data.Proveedor || "").trim(); const tipo = safeString(data.tipo || data.Tipo || "").trim(); const colorCode = safeString(data.colorCode ?? data.color ?? data.Color ?? "").trim().toUpperCase(); const tallaCode = safeString(data.tallaCode ?? data.talla ?? data.Talla ?? "").trim().toUpperCase(); const colorName = safeString(data.colorName ?? data.colorNombre ?? "").trim() || resolveColorName(colorCode); const tallaName = safeString(data.tallaName ?? data.tallaNombre ?? "").trim() || resolveTallaName(tallaCode); const statusRaw = safeString(data.statusCanon ?? data.status ?? data.Status ?? "").trim(); const statusCanon = normalizeStatus(statusRaw); const precioRaw = pickFirstValue( data.precio, data.Precio, data.price, data.precioFinal, data.precio_final ); const precio = toNumberOrNull(precioRaw); const precioConIva = toNumberOrNull( pickFirstValue(data.precioConIva, data.precioConIVA) ); const iva = toNumberOrNull(data.iva ?? data.IVA); const pVenta = toNumberOrNull(data.pVenta ?? data.precioConIva ?? data.precio); const costo = toNumberOrNull(data.costo); const precioConIvaFinal = Number.isFinite(precioConIva) ? precioConIva : (Number.isFinite(pVenta) ? pVenta : null); const isActive = data.isActive !== false; const { utilidad: utilidadCalculada, margenPct: margenCalculado } = computeMarginUtility(precioConIvaFinal, costo); let utilidad = Number(data.utilidad ?? data.Utilidad ?? 0); if ((!utilidad || utilidad === 0) && costo > 0 && precioConIvaFinal > 0) { utilidad = Math.round((precioConIvaFinal - costo) * 100) / 100; } let margen = Number(data.margen ?? data.Margen ?? 0); if ((!margen || margen === 0) && precioConIvaFinal > 0) { margen = Math.round(((utilidad / precioConIvaFinal) * 100) * 10) / 10; } const createdAt = data.createdAt ?? data.CreatedAt ?? null; const fechaAlta = data.fechaAlta ?? data.FechaAlta ?? null; const fecha = data.fecha ?? data.Fecha ?? null; const orden = toNumberOrNull(data.orden ?? data.Orden); const seqNumber = toNumberOrNull(data.seqNumber ?? data.seq ?? data.secuencia); const fechaAltaTexto = safeString( data.fechaAltaTexto ?? data.FechaAltaTexto ?? data.fechaTexto ?? data.FechaTexto ?? "" ).trim(); const fechaTexto = safeString(data.fechaTexto ?? data.FechaTexto ?? "").trim(); const descripcionLower = descripcion.toLowerCase(); const codigoUpper = codigo.toUpperCase(); const _searchKey = `${codigo} ${descripcion}`.toLowerCase().trim(); return { id: docSnap.id, docId: safeString(data.docId ?? docSnap.id), orden: Number.isFinite(orden) ? orden : seqNumber, seqNumber, codigo, descripcion, color: colorName || colorCode, talla: tallaName || tallaCode, colorCode, colorName: colorName || colorCode, tallaCode, tallaName: tallaName || tallaCode, codigoBase: safeString(data.codigoBase ?? data.baseCode ?? parseBaseCode(codigo)).trim() || parseBaseCode(codigo), proveedor, tipo, status: statusCanon, statusCanon, disponibilidad: normalizeDisponibilidad( safeString((data.disponibilidadCanon ?? data.disponibilidad ?? data.Disponibilidad ?? "")).trim(), statusCanon ), disponibilidadCanon: normalizeDisponibilidad( safeString((data.disponibilidadCanon ?? data.disponibilidad ?? data.Disponibilidad ?? "")).trim(), statusCanon ), isActive, precio, pVenta, precioConIva: precioConIvaFinal, iva, costo, margen: Number.isFinite(margen) ? margen : margenCalculado, utilidad: Number.isFinite(utilidad) ? utilidad : utilidadCalculada, createdAt, fechaAlta, fecha, fechaAltaTexto, fechaTexto, descripcionLower, codigoUpper, _searchKey }; }; const getSearchInput = () => safeString(prendasSearch.value); const getSearchTerm = () => normalizeText(prendasSearch.value); const getSearchRaw = () => safeString(prendasSearch.value).trim(); const normalizeSelectValue = (value) => (isAllFilter(value) ? null : value); const getDateRangeFromFilters = (yearValue, monthValue) => { const year = Number(yearValue); if (!Number.isInteger(year) || year < 1900) { return null; } const month = Number(monthValue); if (Number.isInteger(month) && month >= 1 && month <= 12) { return { start: new Date(year, month - 1, 1, 0, 0, 0, 0), end: new Date(year, month, 1, 0, 0, 0, 0), granularity: "month" }; } return { start: new Date(year, 0, 1, 0, 0, 0, 0), end: new Date(year + 1, 0, 1, 0, 0, 0, 0), granularity: "year" }; }; const populateYearFilter = () => { if (!prendasYear) return; const current = prendasYear.value; const thisYear = new Date().getFullYear(); const years = []; for (let year = thisYear; year >= PRENDAS_YEAR_MIN; year -= 1) { years.push(year); } prendasYear.innerHTML = ''; years.forEach((year) => { const option = document.createElement("option"); option.value = String(year); option.textContent = String(year); prendasYear.appendChild(option); }); prendasYear.value = years.includes(Number(current)) ? current : ""; }; const populateMonthFilter = () => { if (!prendasMonth) return; const current = prendasMonth.value; prendasMonth.innerHTML = ''; MONTH_OPTIONS.forEach(({ value, label }) => { const option = document.createElement("option"); option.value = String(value); option.textContent = `${String(value).padStart(2, "0")} - ${label}`; prendasMonth.appendChild(option); }); prendasMonth.value = MONTH_OPTIONS.some(({ value }) => String(value) === current) ? current : ""; }; const syncDateFilterUi = () => { if (!prendasYear || !prendasMonth) return; const hasYear = Boolean(safeString(prendasYear.value).trim()); prendasMonth.disabled = !hasYear; if (!hasYear) { prendasMonth.value = ""; } }; const clearFiltersUi = () => { prendasSearch.value = ""; if (prendasYear) prendasYear.value = ""; if (prendasMonth) prendasMonth.value = ""; prendasPrecioMin.value = ""; prendasPrecioMax.value = ""; if (prendasPrecioSin) prendasPrecioSin.checked = true; syncDateFilterUi(); }; const clearHeaderFilters = () => { prendasState.filters.header = createEmptyHeaderFilters(); updateHeaderFilterButtonsState(); }; const looksLikeCodeSearch = (value) => { const raw = safeString(value).trim().toUpperCase(); if (!raw) return false; return raw.startsWith("HA") || raw.includes("/") || raw.includes("-"); }; const getCurrentGlobalFilters = () => ({ searchText: getSearchRaw(), year: safeString(prendasYear?.value).trim(), month: safeString(prendasMonth?.value).trim(), priceMin: toNumberOrNull(prendasPrecioMin.value), priceMax: toNumberOrNull(prendasPrecioMax.value), includeNoPrice: prendasPrecioSin?.checked ?? true }); const getCurrentFilters = () => ({ global: getCurrentGlobalFilters(), header: prendasState.filters.header }); const isHeaderFilterActive = (key) => { if (key === "pventa") { return ( prendasState.filters.header.pventaMin !== null || prendasState.filters.header.pventaMax !== null ); } if (key === "orden") { return (prendasState.sort?.dir || DEFAULT_PRENDAS_SORT.dir) !== DEFAULT_PRENDAS_SORT.dir; } if (key === "fecha") { return Boolean(prendasState.filters.header.fechaYear || prendasState.filters.header.fechaMonth); } return (prendasState.filters.header[key]?.size || 0) > 0; }; const applyGlobalFilters = (rows, globalFilters) => { const searchText = normalizeText(globalFilters.searchText); const dateRange = getDateRangeFromFilters(globalFilters.year, globalFilters.month); return rows.filter((item) => { const precio = toNum(item.pVenta); if (globalFilters.priceMin != null || globalFilters.priceMax != null) { if (!Number.isFinite(precio)) return false; if (globalFilters.priceMin != null && precio < globalFilters.priceMin) return false; if (globalFilters.priceMax != null && precio > globalFilters.priceMax) return false; } if (dateRange?.start && dateRange?.end && !isInDateRange(item, dateRange)) { return false; } if (!searchText) return true; const codigo = normalizeFilterValue(item.codigo); const descripcion = normalizeFilterValue(item.descripcion); if (looksLikeCodeSearch(searchText)) { return codigo.toUpperCase().startsWith(searchText.toUpperCase()); } return descripcion.includes(searchText); }); }; const applyHeaderFilters = (rows, headerFilters) => rows.filter((item) => { const hasChecklistMatch = HEADER_FILTER_KEYS.every((key) => { const selected = headerFilters[key]; if (!selected || !selected.size) return true; const itemValue = normalizeFilterValue(item[key]); return selected.has(itemValue); }); if (!hasChecklistMatch) return false; // Fecha (año/mes) const hasFechaFilter = Boolean(headerFilters.fechaYear || headerFilters.fechaMonth); if (hasFechaFilter) { const d = resolveComparableDate(item); if (!d) return false; const year = String(d.getFullYear()); const month = String(d.getMonth() + 1).padStart(2, "0"); if (headerFilters.fechaYear && year !== String(headerFilters.fechaYear)) return false; if (headerFilters.fechaMonth && month !== String(headerFilters.fechaMonth)) return false; } // P. Venta (rango) const hasPriceRange = headerFilters.pventaMin != null || headerFilters.pventaMax != null; if (hasPriceRange) { const price = toNum(item.pVenta); if (price === null) return false; if (headerFilters.pventaMin != null && price < headerFilters.pventaMin) return false; if (headerFilters.pventaMax != null && price > headerFilters.pventaMax) return false; } return true; }); const normalizeSortConfig = (sort) => { const rawKey = normalizeText(sort?.key || DEFAULT_PRENDAS_SORT.key); const key = rawKey === "fecha" ? "fecha" : "orden"; const dir = normalizeText(sort?.dir) === "desc" ? "desc" : "asc"; return { key, dir }; }; const updateOrdenSortToggleUi = () => { if (!ordenSortBtn || !ordenSortIcon) return; const sort = normalizeSortConfig(prendasState.sort); ordenSortIcon.textContent = sort.dir === "desc" ? "↓" : "↑"; ordenSortBtn.setAttribute("aria-label", `Ordenar por Orden (${sort.dir === "desc" ? "descendente" : "ascendente"})`); }; const persistSort = (sort) => { try { safeStorageSet(window.localStorage, PRENDAS_SORT_KEY_STORAGE, sort.key); safeStorageSet(window.localStorage, PRENDAS_SORT_DIR_STORAGE, sort.dir); } catch (_) {} }; const restorePersistedSort = () => { try { const key = safeStorageGet(window.localStorage, PRENDAS_SORT_KEY_STORAGE, ""); const dir = safeStorageGet(window.localStorage, PRENDAS_SORT_DIR_STORAGE, ""); return normalizeSortConfig({ key, dir }); } catch (_) { return { ...DEFAULT_PRENDAS_SORT }; } }; const setPrendasSort = async (nextSort, { refresh = true } = {}) => { prendasState.sort = normalizeSortConfig(nextSort); prendasState.pageIndex = 0; persistSort(prendasState.sort); updateOrdenSortToggleUi(); updateHeaderFilterButtonsState(); if (refresh && !isBooting) { await loadPrendasPage({ reset: true, reason: "sort-change" }); } }; const resolveRowOrderValue = (row, index) => { const candidates = [row?.orden, row?._index, row?.__i, row?._rowNumber, row?.__order]; for (const value of candidates) { const parsed = toNumberOrNull(value); if (Number.isFinite(parsed)) return parsed; } return index + 1; }; const sortRows = (rows = [], sort = prendasState.sort) => { const activeSort = normalizeSortConfig(sort); const direction = activeSort.dir === "desc" ? -1 : 1; const indexedRows = rows.map((row, index) => ({ row, index })); return indexedRows.sort((a, b) => { if (activeSort.key === "orden") { const orderA = resolveRowOrderValue(a.row, a.index); const orderB = resolveRowOrderValue(b.row, b.index); if (orderA !== orderB) { return (orderA - orderB) * direction; } return safeString(a.row?.codigo).localeCompare(safeString(b.row?.codigo), "es", { numeric: true, sensitivity: "base" }) * direction; } if (activeSort.key === "fecha") { const timeA = resolveComparableDate(a.row)?.getTime() ?? 0; const timeB = resolveComparableDate(b.row)?.getTime() ?? 0; if (timeA !== timeB) { return (timeA - timeB) * direction; } return safeString(a.row?.codigo).localeCompare(safeString(b.row?.codigo), "es", { numeric: true, sensitivity: "base" }) * direction; } return safeString(a.row?.[activeSort.key]).localeCompare(safeString(b.row?.[activeSort.key]), "es", { numeric: true, sensitivity: "base" }) * direction; }).map(({ row }) => row); }; const applyAllFilters = (rows) => { const safeRows = Array.isArray(rows) ? rows : []; const globalFilters = getCurrentGlobalFilters(); prendasState.filters.global = globalFilters; const hasPrice = (row) => { const p = row?.pVenta; if (p === null || p === undefined) return false; const n = Number(p); return Number.isFinite(n) && n > 0; }; let rowsAfterNoPrice = safeRows; if (!globalFilters.includeNoPrice) { rowsAfterNoPrice = safeRows.filter(hasPrice); } console.log( "[Prendas] incluirSinPrecio:", globalFilters.includeNoPrice, "rows:", rowsAfterNoPrice.length ); const rowsAfterGlobal = applyGlobalFilters(rowsAfterNoPrice, globalFilters); prendasState.rowsAfterGlobal = rowsAfterGlobal; let rowsAfterAll = applyHeaderFilters(rowsAfterGlobal, prendasState.filters.header); rowsAfterAll = sortRows(rowsAfterAll, prendasState.sort); prendasState.rowsAfterAll = rowsAfterAll; return rowsAfterAll; }; const hasActiveFilters = (filters) => { if (!filters) return false; const globalFilters = filters.global || {}; const headerFilters = filters.header || {}; const hasSearch = Boolean(safeString(globalFilters.searchText).trim()); const hasDate = Boolean(globalFilters.year); const hasPriceRange = globalFilters.priceMin !== null || globalFilters.priceMax !== null; const excludeNoPrice = globalFilters.includeNoPrice === false; const hasHeaderFilters = HEADER_FILTER_KEYS.some((key) => (headerFilters[key]?.size || 0) > 0); const hasHeaderPrice = headerFilters.pventaMin !== null || headerFilters.pventaMax !== null; return hasSearch || hasDate || hasPriceRange || excludeNoPrice || hasHeaderFilters || hasHeaderPrice; }; const getUniqueOptions = (rows, key) => { const safeRows = Array.isArray(rows) ? rows : []; const set = new Set(); safeRows.forEach((row) => { const value = safeString(row[key]).trim(); if (value) set.add(value); }); return [...set].sort((a, b) => a.localeCompare(b, "es", { sensitivity: "base" })); }; const handleIndexError = (error) => { const message = error?.message || ""; const isIndexError = error?.code === "failed-precondition" || /requires an index/i.test(message) || /indexes/i.test(message); if (!isIndexError) { return false; } console.warn("[Prendas] Index error", error); return true; }; const updateHeaderFilterButtonsState = () => { headerFilterButtons.forEach((button) => { const key = button.dataset.openHeaderFilter; button.classList.toggle("active", isHeaderFilterActive(key)); }); updateOrdenSortToggleUi(); }; const closeHeaderPopover = () => { if (prendasState.headerPopover?.cleanup) { prendasState.headerPopover.cleanup(); } if (prendasState.headerPopover?.container) { prendasState.headerPopover.container.remove(); } prendasState.headerPopover = null; }; const clamp = (value, min, max) => Math.min(Math.max(value, min), max); const isMobileViewport = () => window.matchMedia("(max-width: 768px)").matches; const positionHeaderPopover = () => { if (!prendasState.headerPopover?.container || !prendasState.headerPopover?.anchorButton) { return; } if (prendasState.headerPopover?.isMobileSheet) { return; } const { container, anchorButton } = prendasState.headerPopover; const anchorRect = anchorButton.getBoundingClientRect(); const popoverRect = container.getBoundingClientRect(); let left = anchorRect.right - popoverRect.width; let top = anchorRect.bottom + HEADER_POPOVER_OFFSET; const maxLeft = window.innerWidth - popoverRect.width - HEADER_POPOVER_MARGIN; const maxTop = window.innerHeight - popoverRect.height - HEADER_POPOVER_MARGIN; if (top + popoverRect.height > window.innerHeight - HEADER_POPOVER_MARGIN) { top = anchorRect.top - popoverRect.height - HEADER_POPOVER_OFFSET; } left = clamp(left, HEADER_POPOVER_MARGIN, maxLeft); top = clamp(top, HEADER_POPOVER_MARGIN, maxTop); container.style.left = `${left}px`; container.style.top = `${top}px`; }; const bindHeaderPopoverPositioning = (container, anchorButton, key) => { const mobileSheet = isMobileViewport(); if (mobileSheet) { container.classList.add("mobile-sheet"); } const handleResize = () => positionHeaderPopover(); const handleScroll = () => positionHeaderPopover(); window.addEventListener("resize", handleResize); window.addEventListener("scroll", handleScroll, true); prendasState.headerPopover = { key, container, anchorButton, isMobileSheet: mobileSheet, cleanup: () => { window.removeEventListener("resize", handleResize); window.removeEventListener("scroll", handleScroll, true); } }; requestAnimationFrame(positionHeaderPopover); }; const renderHeaderFilterPopover = (key, anchorButton) => { closeHeaderPopover(); const container = document.createElement("div"); container.className = "filter-popover"; if (key === HEADER_RANGE_KEY) { container.classList.add("range-popover"); const currentMin = prendasState.filters.header.pventaMin; const currentMax = prendasState.filters.header.pventaMax; container.innerHTML = `

Filtrar P. Venta

`; document.body.appendChild(container); const minInput = container.querySelector("[data-range-min]"); const maxInput = container.querySelector("[data-range-max]"); const hintEl = container.querySelector("[data-range-hint]"); // ✅ FIX: no metas ${...} literal en HTML. Seteamos el valor por JS. try { minInput.value = (typeof currentMin !== "undefined" && currentMin !== null) ? String(currentMin) : ""; maxInput.value = (typeof currentMax !== "undefined" && currentMax !== null) ? String(currentMax) : ""; } catch (e) {} container.querySelector("[data-clear]").addEventListener("click", async () => { prendasState.filters.header.pventaMin = null; prendasState.filters.header.pventaMax = null; closeHeaderPopover(); await applyFilters(); }); container.querySelector("[data-apply]").addEventListener("click", async () => { let minValue = toNumberOrNull(minInput.value); let maxValue = toNumberOrNull(maxInput.value); if (minValue !== null && maxValue !== null && minValue > maxValue) { [minValue, maxValue] = [maxValue, minValue]; hintEl.textContent = "Se ajustó el rango automáticamente (mín ↔ máx)."; } prendasState.filters.header.pventaMin = minValue; prendasState.filters.header.pventaMax = maxValue; closeHeaderPopover(); await applyFilters(); }); bindHeaderPopoverPositioning(container, anchorButton, key); return; } if (key === "fecha") { const monthNames = [ "enero","febrero","marzo","abril","mayo","junio", "julio","agosto","septiembre","octubre","noviembre","diciembre" ]; const availableYears = Array.from(new Set((prendasState.baseRows || []) .map((it) => resolveComparableDate(it)) .filter(Boolean) .map((d) => d.getFullYear()))) .sort((a,b) => b - a); const currentYear = safeString(prendasState.filters.header.fechaYear).trim(); const currentMonth = safeString(prendasState.filters.header.fechaMonth).trim(); const yearOptions = [''] .concat(availableYears.map((y) => ``)) .join(""); const monthOptions = [''] .concat(monthNames.map((name, i) => { const v = String(i + 1).padStart(2, "0"); return ``; })) .join(""); container.innerHTML = `

Filtrar Fecha

`; document.body.appendChild(container); const yearSelect = container.querySelector("[data-fecha-year]"); const monthSelect = container.querySelector("[data-fecha-month]"); container.querySelector("[data-clear]").addEventListener("click", async () => { prendasState.filters.header.fechaYear = ""; prendasState.filters.header.fechaMonth = ""; closeHeaderPopover(); await applyFilters(); }); container.querySelector("[data-apply]").addEventListener("click", async () => { prendasState.filters.header.fechaYear = safeString(yearSelect.value).trim(); prendasState.filters.header.fechaMonth = safeString(monthSelect.value).trim(); closeHeaderPopover(); await applyFilters(); }); bindHeaderPopoverPositioning(container, anchorButton, key); return; } const options = prendasState.uniqueOptions[key] || []; const selected = new Set(prendasState.filters.header[key] || []); container.innerHTML = `

Filtrar ${key}

`; document.body.appendChild(container); const optionsEl = container.querySelector("[data-options]"); const searchEl = container.querySelector("[data-filter-search]"); const selectAllEl = container.querySelector("[data-select-all]"); const countEl = container.querySelector("[data-filter-count]"); const updateCounters = () => { const total = options.length; const selectedCount = selected.size; countEl.textContent = `(${selectedCount} seleccionados / ${total} total)`; selectAllEl.checked = total > 0 && selectedCount === total; }; const refreshList = () => { const q = normalizeText(searchEl.value); const visible = options.filter((value) => normalizeText(value).includes(q)); optionsEl.textContent = ""; visible.forEach((value) => { const row = document.createElement("label"); row.className = "option-row"; const checked = selected.has(normalizeFilterValue(value)); row.innerHTML = ` ${value}`; const checkbox = row.querySelector("input"); checkbox.addEventListener("change", () => { const normalized = normalizeFilterValue(value); if (checkbox.checked) selected.add(normalized); else selected.delete(normalized); updateCounters(); }); optionsEl.appendChild(row); }); updateCounters(); }; selectAllEl.addEventListener("change", () => { if (selectAllEl.checked) { options.forEach((value) => selected.add(normalizeFilterValue(value))); } else { selected.clear(); } refreshList(); }); searchEl.addEventListener("input", refreshList); container.querySelector("[data-clear]").addEventListener("click", async () => { prendasState.filters.header[key] = new Set(); closeHeaderPopover(); await applyFilters(); }); container.querySelector("[data-apply]").addEventListener("click", async () => { prendasState.filters.header[key] = selected; closeHeaderPopover(); await applyFilters(); }); bindHeaderPopoverPositioning(container, anchorButton, key); refreshList(); }; const chunkArray = (items, size) => { const chunks = []; for (let i = 0; i < items.length; i += size) { chunks.push(items.slice(i, i + size)); } return chunks; }; const isMissingIndexError = (error) => { const message = (error && (error.message || error.toString())) || ""; return message.includes("requires an index") || message.includes("FAILED_PRECONDITION"); }; const getDocsWithIndexFallback = async (primaryQuery, fallbackQuery, tag = "Query") => { try { return { snapshot: await getDocs(primaryQuery), usedFallback: false, }; } catch (error) { if (isMissingIndexError(error) && fallbackQuery) { console.warn(`[${tag}] Missing index -> fallback`, error); return { snapshot: await getDocs(fallbackQuery), usedFallback: true, }; } throw error; } }; const loadAdminCostMap = async (rows = []) => { const map = new Map(); const rowDocIds = rows .map((row) => safeString(row.docId || row.id).trim()) .filter(Boolean); if (!rowDocIds.length) { return map; } const ids = [...new Set(rowDocIds)]; const chunks = chunkArray(ids, 30); for (const chunk of chunks) { const snap = await getDocs( query(collection(db, PRENDAS_ADMIN_COLLECTION), where(documentId(), "in", chunk)) ); snap.docs.forEach((docSnap) => { const data = docSnap.data() || {}; const lookupId = safeString(docSnap.id).trim(); if (!lookupId) return; map.set(lookupId, { costo: toNumberOrNull(data.costo), pVentaOverride: toNumberOrNull(data.pVentaOverride ?? data.pVenta), margen: toNumberOrNull(data.margen), utilidad: toNumberOrNull(data.utilidad), }); }); } prendasState.adminMap = map; return map; }; const mergeAdminFields = async (rows = []) => { if (!(isAdminViewEnabled() && rows.length)) { prendasState.adminMap.clear(); return rows; } const adminCostMap = await loadAdminCostMap(rows); rows.forEach((row) => { const lookupId = safeString(row.docId || row.id).trim(); const adminExtra = adminCostMap.get(lookupId); if (!adminExtra) return; const costo = toNumberOrNull(adminExtra.costo); const utilidadFromAdmin = toNumberOrNull(adminExtra.utilidad); const margenFromAdmin = toNumberOrNull(adminExtra.margen); const pVentaOverride = toNumberOrNull(adminExtra.pVentaOverride); row.costo = Number.isFinite(costo) ? costo : null; row.pVentaOverride = Number.isFinite(pVentaOverride) ? pVentaOverride : null; const precioVisible = Number.isFinite(row.pVentaOverride) ? row.pVentaOverride : (Number.isFinite(row.pVenta) ? row.pVenta : null); row.pVentaDisplay = precioVisible; const { utilidad: utilidadCalculada, margenPct: margenCalculado } = computeMarginUtility(precioVisible, row.costo); row.utilidad = (Number.isFinite(utilidadFromAdmin) && utilidadFromAdmin !== 0) ? utilidadFromAdmin : utilidadCalculada; row.margen = (Number.isFinite(margenFromAdmin) && margenFromAdmin !== 0) ? Number(margenFromAdmin.toFixed(1)) : margenCalculado; }); return rows; }; const loadBaseRows = async () => { const cacheKey = "public"; if (prendasState.baseRows.length && prendasState.baseRowsCacheKey === cacheKey) { return prendasState.baseRows; } let cursor = null; let hasMore = true; let queryWithoutIsActiveFilter = false; const rows = []; try { while (hasMore) { const primaryConstraints = [where("isActive", "==", true), orderBy("orden", "asc"), limit(PRENDAS_COUNT_BATCH_SIZE)]; const fallbackConstraints = [orderBy("orden", "asc"), limit(PRENDAS_COUNT_BATCH_SIZE)]; if (cursor) { primaryConstraints.splice(primaryConstraints.length - 1, 0, startAfter(cursor)); fallbackConstraints.splice(fallbackConstraints.length - 1, 0, startAfter(cursor)); } const baseQuery = queryWithoutIsActiveFilter ? query(prendasPublicRef, ...fallbackConstraints) : query(prendasPublicRef, ...primaryConstraints); const fallbackQuery = query(prendasPublicRef, ...fallbackConstraints); const { snapshot: snap, usedFallback } = await getDocsWithIndexFallback( baseQuery, queryWithoutIsActiveFilter ? null : fallbackQuery, "Prendas" ); if (usedFallback && !queryWithoutIsActiveFilter) { queryWithoutIsActiveFilter = true; cursor = null; rows.length = 0; console.warn("[Prendas] isActive filter no disponible, usando fallback sin filtro de query"); continue; } console.log("[Panel] snapshot size:", snap.size); if (!snap.empty && !debugState.sampleLogged) { console.log("[Prendas] sample doc data:", snap.docs[0].data()); debugState.sampleLogged = true; } debugState.snapshotSizes.baseQuery = snap.size; updateDebugPanel(); if (snap.empty) { if (!cursor && !queryWithoutIsActiveFilter) { queryWithoutIsActiveFilter = true; cursor = null; rows.length = 0; console.warn("[Prendas] Query con isActive devolvió vacío. Reintentando sin filtro isActive."); continue; } hasMore = false; break; } snap.docs.forEach((docSnap) => rows.push(normalizeDoc(docSnap))); cursor = snap.docs[snap.docs.length - 1]; if (snap.docs.length < PRENDAS_COUNT_BATCH_SIZE) { hasMore = false; } } } catch (error) { console.error("[Panel] Error Firestore:", error); if (isEmbedded()) { console.warn("[Prendas] Public falló en embed, no se intentará master.", error); setPrendasAlert("No se pudieron cargar prendas en modo embebido. Mostrando lista vacía.", { isHtml: false }); } else { setPrendasAlert("No se pudo cargar la base pública. Verifica permisos o conexión.", { isHtml: false }); } return []; } let rowsToRender = rows.filter((row) => row.isActive !== false); if (!rowsToRender.length && rows.length) { console.warn("[Prendas] Todas las filas quedaron inactivas por isActive. Mostrando todo por tolerancia."); rowsToRender = rows; } rowsToRender.forEach((row) => { row.costo = null; row.utilidad = null; row.margen = null; }); const orderedRows = rowsToRender.sort(compareItemsByOrdenAsc); orderedRows.forEach((row, index) => { const generatedOrder = index + 1; row.__order = generatedOrder; row._rowNumber = generatedOrder; if (!Number.isFinite(toNumberOrNull(row.orden))) { row.orden = generatedOrder; } }); prendasState.baseRows = orderedRows; prendasState.baseRowsCacheKey = cacheKey; return prendasState.baseRows; }; const resetPrendasState = () => { prendasState.filtered = []; prendasState.baseRows = []; prendasState.baseRowsCacheKey = prendasState.isAdmin ? "admin" : "public"; prendasState.rowsAfterGlobal = []; prendasState.rowsAfterAll = []; prendasState.isDone = false; prendasState.filtersActive = false; prendasState.showingTotal = null; prendasState.queryKey = ""; prendasState.pageIndex = 0; prendasState.hasNextPage = false; prendasState.filters.global = getCurrentGlobalFilters(); prendasState.filters.header = createEmptyHeaderFilters(); prendasState.sort = restorePersistedSort(); prendasState.uniqueOptions = {}; prendasState.adminMap.clear(); prendasState.selected.clear(); closeHeaderPopover(); resetCounters(); }; const renderLoadingPlaceholder = () => { if (!prendasTbody) return; prendasTbody.textContent = ""; const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 17; cell.textContent = "Cargando base de datos..."; row.appendChild(cell); prendasTbody.appendChild(row); setCounter(0, prendasState.showingTotal); }; const refreshExactCounts = (rowsAfterAll) => { const total = rowsAfterAll.length; const nuevos = rowsAfterAll.filter((item) => isNew(getPrendaDate(item))).length; const existentes = total - nuevos; setApproxCounts({ total, nuevos, existentes, isApprox: false }); }; const getLoadingMessageByReason = (reason) => { if (["filters", "filters-clear", "search-clear"].includes(reason)) { return "Aplicando filtros..."; } if (["pagination-prev", "pagination-next"].includes(reason)) { return "Cargando página..."; } if (reason === "page-size") { return "Actualizando cantidad por página..."; } return "Cargando base de datos..."; }; const loadPrendasPage = async ({ pageIndex = null, reset = false, reason = "manual" } = {}) => { if (prendasState.isLoadingInitial) { return; } setSkuSearchMode(false); setPrendasAlert(""); prendasState.isLoadingInitial = true; setLoading(true, getLoadingMessageByReason(reason)); try { if (reset) { prendasState.pageIndex = 0; renderLoadingPlaceholder(); } const baseRows = await loadBaseRows(); const rows = Array.isArray(baseRows) ? baseRows : []; console.log("[Prendas] baseRows:", rows.length); const rowsAfterAll = applyAllFilters(rows); HEADER_FILTER_KEYS.forEach((key) => { prendasState.uniqueOptions[key] = getUniqueOptions(prendasState.rowsAfterGlobal, key); }); updateHeaderFilterButtonsState(); const pageSize = getPageSize(); const totalRows = Array.isArray(rowsAfterAll) ? rowsAfterAll.length : 0; const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)); const requestedPageIndex = Number.isFinite(Number(pageIndex)) ? Number(pageIndex) : prendasState.pageIndex; const safePageIndex = Math.min(Math.max(requestedPageIndex, 0), totalPages - 1); const start = safePageIndex * pageSize; const safeRowsAfterAll = Array.isArray(rowsAfterAll) ? rowsAfterAll : []; const items = safeRowsAfterAll.slice(start, start + pageSize); prendasState.filtered = items; await mergeAdminFields(items); prendasState.pageIndex = safePageIndex; prendasState.hasNextPage = safePageIndex < totalPages - 1; prendasState.filtersActive = hasActiveFilters(getCurrentFilters()); prendasState.showingTotal = totalRows; await renderDocs(items); refreshExactCounts(safeRowsAfterAll); if (prendasState.filtersActive && !items.length) { setPrendasAlert("No hay resultados con los filtros actuales."); } } catch (error) { console.error(error); setPrendasAlert("No se pudo cargar la base de datos de códigos."); } finally { prendasState.isLoadingInitial = false; setLoading(false); updatePaginationUi(); } }; const applyFilters = async () => { await loadPrendasPage({ reset: true, reason: "filters" }); }; const normalizeFilterValue = (value) => normalizeText(value); const toNum = (value) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }; const getPageSize = () => { const selected = Number(prendasPageSize?.value); if (Number.isFinite(selected) && selected > 0) { return selected; } return PRENDAS_PAGE_SIZE_DEFAULT; }; const renderRowsChunked = ( rows, tbody, chunkSize = PRENDAS_RENDER_CHUNK, token ) => new Promise((resolve) => { let index = 0; const appendChunk = () => { if (token && token !== prendasState.renderToken) { resolve(0); return; } const fragment = document.createDocumentFragment(); for (let i = 0; i < chunkSize && index < rows.length; i += 1) { fragment.appendChild(createPrendaRow(rows[index])); index += 1; } tbody.appendChild(fragment); if (index < rows.length) { requestAnimationFrame(appendChunk); return; } resolve(0); }; requestAnimationFrame(appendChunk); }); const buildBadge = (text, className) => { const badge = document.createElement("span"); badge.className = `badge ${className}`; badge.textContent = text; return badge; }; const createPrendaRow = (item) => { const adminEnabled = isAdminViewEnabled(); const ordenValue = Number(item.orden); const orden = Number.isFinite(ordenValue) ? String(ordenValue) : ""; const codigo = safeString(item.codigo || item.code) || "--"; const descripcion = safeString(item.descripcion) || "--"; const tipo = formatLabel(safeString(item.tipo)) || "--"; const color = safeString(item.colorName) || formatLabel(safeString(item.color)) || "--"; const talla = formatLabel(safeString(item.talla)) || "--"; const proveedor = formatLabel(safeString(item.proveedor)) || "--"; const statusCell = renderStatusCell(item.statusCanon || item.status); const disponibilidadCell = renderDisponibilidadCell(item.disponibilidadCanon || item.disponibilidad, item.statusCanon || item.status); const fecha = formatDateCell(item); const pVentaVisible = adminEnabled ? (toNumber(item.pVentaDisplay) ?? toNumber(item.pVenta)) : toNumber(item.pVenta); const pVenta = moneyFmt(pVentaVisible); const costo = moneyFmt(item.costo); const margen = pctFmt(item.margen); const utilidad = moneyFmt(item.utilidad); const row = document.createElement("tr"); const docId = safeString(item.docId || item.id).trim(); row.dataset.docId = docId; row.className = isNew(getPrendaDate(item)) ? "row-new" : "row-old"; const selectCell = document.createElement("td"); selectCell.className = "col-select"; const selectInput = document.createElement("input"); selectInput.type = "checkbox"; selectInput.className = "row-select"; selectInput.dataset.docId = docId; selectInput.checked = prendasState.selected.has(docId); selectCell.appendChild(selectInput); row.appendChild(selectCell); const baseCells = [orden, codigo, descripcion, tipo, color, talla, proveedor, statusCell, disponibilidadCell, fecha, pVenta]; const baseClasses = [ "col-orden","col-codigo","col-descripcion col-desc","col-tipo","col-color","col-talla","col-proveedor col-prov","col-status","col-disponibilidad col-disp","col-fecha","col-pventa col-precio" ]; baseCells.forEach((value, index) => { if (value instanceof HTMLElement) { const cls = (baseClasses[index] || "").split(/\s+/).filter(Boolean); if (cls.length) value.classList.add(...cls); row.appendChild(value); return; } const cell = document.createElement("td"); cell.textContent = value; const cls = (baseClasses[index] || "").split(/\s+/).filter(Boolean); if (cls.length) cell.classList.add(...cls); if (index === 2) { cell.classList.add("cell-desc"); if (ENABLE_DESC_BETTER_UI) { cell.classList.add("haruja-col-desc"); const descWrap = document.createElement("div"); descWrap.className = "clamp haruja-clamp"; descWrap.title = value; descWrap.textContent = value; cell.textContent = ""; cell.appendChild(descWrap); } else { cell.title = value; } } row.appendChild(cell); }); if (adminEnabled) { const adminValues = [costo, margen, utilidad]; const adminClasses = ["col-costo", "col-margen", "col-utilidad"]; adminValues.forEach((value, index) => { const cell = document.createElement("td"); cell.textContent = value; cell.classList.add(adminClasses[index]); row.appendChild(cell); }); } if (adminEnabled) { const actionCell = document.createElement("td"); actionCell.className = "col-actions"; // ✏️ Editar const editButton = document.createElement("button"); editButton.type = "button"; editButton.className = "rowEditBtn"; editButton.dataset.editDoc = docId; editButton.setAttribute("aria-label", "Editar prenda"); editButton.title = "Editar"; editButton.textContent = "✏️"; // 🗑 Borrar const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "rowDeleteBtn"; deleteButton.dataset.deleteDoc = docId; deleteButton.setAttribute("aria-label", "Borrar prenda"); deleteButton.title = "Borrar"; deleteButton.textContent = "🗑"; actionCell.appendChild(editButton); actionCell.appendChild(deleteButton); row.appendChild(actionCell); } return row; }; const updateEditPreview = () => { if (!editPVentaInput || !editCostoInput) return; const pOverride = toNumber(editPVentaInput.value); const pPublic = toNumber(prendasState.editing?.publicPVenta); const p = Number.isFinite(pOverride) ? pOverride : (pPublic ?? 0); const c = toNumber(editCostoInput.value) ?? 0; const { utilidad, margenPct } = computeMarginUtility(p, c); if (editUtilidadPreview) editUtilidadPreview.textContent = moneyFmt(utilidad); if (editMargenPreview) editMargenPreview.textContent = pctFmt(margenPct); const baseCode = parseBaseCode(prendasState.editing?.codigoBase || prendasState.editing?.codigo || ""); const colorCode = safeString(editColorInput?.value || prendasState.editing?.colorCode).trim().toUpperCase(); const tallaCode = safeString(editTallaInput?.value || prendasState.editing?.tallaCode).trim().toUpperCase(); if (editCodigoPreview) { editCodigoPreview.textContent = (baseCode && colorCode && tallaCode) ? `${baseCode}/${colorCode}-${tallaCode}` : "--"; } }; const closeEditModal = () => { prendasState.editing = null; if (editModal) { editModal.classList.add("hidden"); editModal.setAttribute("aria-hidden", "true"); } if (editModalError) { editModalError.textContent = ""; editModalError.classList.add("hidden"); } }; const openEditModal = (rowData) => { if (!editModal || !rowData) return; const publicPVenta = toNumber(rowData.pVenta); const pVentaOverride = toNumber(rowData.pVentaOverride); const colorCode = safeString(rowData.colorCode || rowData.color).trim().toUpperCase(); const tallaCode = safeString(rowData.tallaCode || rowData.talla).trim().toUpperCase(); prendasState.editing = { docId: safeString(rowData.docId || rowData.id).trim(), codigo: safeString(rowData.codigo).trim(), codigoBase: parseBaseCode(rowData.codigoBase || rowData.codigo), colorCode, tallaCode, publicPVenta, rowDataSnapshot: { ...rowData } }; if (editPVentaInput) editPVentaInput.value = Number.isFinite(pVentaOverride) ? pVentaOverride : ""; if (editCostoInput) editCostoInput.value = toNumber(rowData.costo) ?? ""; if (editDescripcionInput) editDescripcionInput.value = safeString(rowData.descripcion).trim(); if (editColorInput) editColorInput.value = colorCode; if (editTallaInput) editTallaInput.value = tallaCode; if (editModalError) { editModalError.textContent = ""; editModalError.classList.add("hidden"); } updateEditPreview(); editModal.classList.remove("hidden"); editModal.setAttribute("aria-hidden", "false"); }; const updateRowInMemory = (docId, updates) => { const applyTo = (rows = []) => { rows.forEach((row) => { const rowDocId = safeString(row.docId || row.id).trim(); if (rowDocId !== docId) return; Object.assign(row, updates); }); }; applyTo(prendasState.baseRows); applyTo(prendasState.rowsAfterAll); applyTo(prendasState.rowsAfterGlobal); applyTo(prendasState.filtered); }; const ensureAdminSession = async () => { let user = auth.currentUser; if (!user) { user = await signInWithGoogleForContext(); } if (!user) { throw new Error("Iniciando autenticación por redirect..."); } const email = safeString(user?.email).trim().toLowerCase(); if (!isAllowlistedAdmin(email)) { if (user) { await signOut(auth); } throw new Error("Usuario no autorizado para editar."); } return user; }; const saveEdit = async () => { try { if (!ensureAdminMode()) return; if (!prendasState.editing?.docId) return; const oldDocId = prendasState.editing.docId; const pVentaOverride = toNumber(editPVentaInput?.value); const costo = toNumber(editCostoInput?.value); const descripcion = safeString(editDescripcionInput?.value).trim(); const colorCode = safeString(editColorInput?.value).trim().toUpperCase(); const tallaCode = safeString(editTallaInput?.value).trim().toUpperCase(); const colorName = resolveColorName(colorCode) || colorCode; const tallaName = resolveTallaName(tallaCode) || tallaCode; const codigoBase = parseBaseCode(prendasState.editing?.codigoBase || prendasState.editing?.codigo || ""); if (!codigoBase || !colorCode || !tallaCode) { throw new Error("Color y talla son requeridos para calcular el código."); } const newCodigo = `${codigoBase}/${colorCode}-${tallaCode}`; const newDocId = safeDocId(newCodigo); if (Number.isFinite(pVentaOverride) && pVentaOverride < 0) { throw new Error("P.Venta debe ser mayor o igual a 0."); } if (Number.isFinite(costo) && costo < 0) { throw new Error("Costo debe ser mayor o igual a 0."); } if (!descripcion) { throw new Error("Descripción requerida."); } editModalSaveButton.disabled = true; const user = await ensureAdminSession(); const userEmail = safeString(user?.email).trim().toLowerCase(); const oldPublicRef = doc(db, activePrendasPublicCollection, oldDocId); const nextPublicPayload = { docId: newDocId, codigo: newCodigo, code: newCodigo, codigoBase, baseCode: codigoBase, color: colorCode, colorCode, colorName, talla: tallaCode, tallaCode, tallaName, descripcion, updatedAt: serverTimestamp(), updatedBy: userEmail }; if (newDocId === oldDocId) { await updateDoc(oldPublicRef, nextPublicPayload); } else { const nextPublicRef = doc(db, activePrendasPublicCollection, newDocId); const nextPublicSnap = await getDoc(nextPublicRef); if (nextPublicSnap.exists()) { throw new Error("Ya existe ese código."); } const oldSnap = await getDoc(oldPublicRef); if (!oldSnap.exists()) { throw new Error("La prenda original ya no existe."); } await setDoc(nextPublicRef, { ...oldSnap.data(), ...nextPublicPayload, migratedFrom: safeString(prendasState.editing?.codigo || oldDocId).trim() }); await deleteDoc(oldPublicRef); } const pVentaVisible = Number.isFinite(pVentaOverride) ? pVentaOverride : toNumber(prendasState.editing?.publicPVenta); const { utilidad, margenPct } = computeMarginUtility(pVentaVisible, costo); const oldAdminRef = doc(db, PRENDAS_ADMIN_COLLECTION, oldDocId); const nextAdminRef = doc(db, PRENDAS_ADMIN_COLLECTION, newDocId); await setDoc(nextAdminRef, { docId: newDocId, codigo: newCodigo, costo: Number.isFinite(costo) ? costo : deleteField(), pVentaOverride: Number.isFinite(pVentaOverride) ? pVentaOverride : deleteField(), utilidad: (Number.isFinite(pVentaVisible) && Number.isFinite(costo)) ? utilidad : deleteField(), margen: (Number.isFinite(pVentaVisible) && Number.isFinite(costo)) ? margenPct : deleteField(), updatedAt: serverTimestamp(), updatedBy: userEmail }, { merge: true }); if (newDocId !== oldDocId) { await deleteDoc(oldAdminRef); prendasState.adminMap.delete(oldDocId); } prendasState.adminMap.set(newDocId, { costo: Number.isFinite(costo) ? costo : null, pVentaOverride: Number.isFinite(pVentaOverride) ? pVentaOverride : null, utilidad: (Number.isFinite(pVentaVisible) && Number.isFinite(costo)) ? utilidad : null, margen: (Number.isFinite(pVentaVisible) && Number.isFinite(costo)) ? margenPct : null }); const nextPVenta = Number.isFinite(pVentaOverride) ? pVentaOverride : toNumber(prendasState.editing?.publicPVenta); const hasMarginData = Number.isFinite(nextPVenta) && Number.isFinite(costo); if (newDocId !== oldDocId) { const removeFrom = (rows = []) => { const idx = rows.findIndex((row) => safeString(row.docId || row.id).trim() === oldDocId); if (idx >= 0) rows.splice(idx, 1); }; removeFrom(prendasState.baseRows); removeFrom(prendasState.rowsAfterAll); removeFrom(prendasState.rowsAfterGlobal); removeFrom(prendasState.filtered); } updateRowInMemory(newDocId, { docId: newDocId, id: newDocId, codigo: newCodigo, codigoBase, color: colorName, colorCode, colorName, talla: tallaName, tallaCode, tallaName, descripcion, costo: Number.isFinite(costo) ? costo : null, pVentaOverride: Number.isFinite(pVentaOverride) ? pVentaOverride : null, pVentaDisplay: Number.isFinite(nextPVenta) ? nextPVenta : null, utilidad: hasMarginData ? utilidad : null, margen: hasMarginData ? Number(margenPct.toFixed(1)) : null }); await loadPrendasPage({ reset: false, reason: "edit-save" }); setPrendasAlert("Prenda guardada.", "success"); closeEditModal(); } catch (error) { if (editModalError) { editModalError.textContent = error?.message || "No se pudo guardar."; editModalError.classList.remove("hidden"); } console.error(error); } finally { if (editModalSaveButton) { editModalSaveButton.disabled = false; } } }; const handleDeleteRow = async (rowData) => { if (!rowData) return; if (!ensureAdminMode()) return; const docId = safeString(rowData.docId || rowData.id).trim(); if (!docId) return; const codigo = safeString(rowData.codigo).trim() || docId; const confirmed = window.confirm(`¿Eliminar ${codigo}? Esta acción no se puede deshacer.`); if (!confirmed) return; const user = await ensureAdminSession(); const userEmail = safeString(user?.email).trim().toLowerCase() || "public"; await updateDoc(doc(db, activePrendasPublicCollection, docId), { isActive: false, status: "ELIMINADO", deleted: true, deletedAt: serverTimestamp(), deletedBy: userEmail, updatedAt: serverTimestamp(), updatedBy: userEmail }); await loadPrendasPage({ reset: false, reason: "delete-row" }); setPrendasAlert(`Prenda ${codigo} eliminada.`, "success"); }; const renderDocs = async (rows) => { const safeRows = Array.isArray(rows) ? rows : []; prendasState.renderToken += 1; const token = prendasState.renderToken; const showingTotal = Number.isFinite(prendasState.showingTotal) ? prendasState.showingTotal : null; setCounter(safeRows.length, showingTotal); updatePaginationUi(); prendasTbody.textContent = ""; if (!safeRows.length) { let emptyNode = null; if (prendasEmptyRow) { emptyNode = prendasEmptyRow instanceof HTMLTemplateElement ? prendasEmptyRow.content.cloneNode(true) : prendasEmptyRow.cloneNode(true); } if (!emptyNode) { const emptyRow = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 17; cell.textContent = "Sin resultados"; emptyRow.appendChild(cell); emptyNode = emptyRow; } prendasTbody.appendChild(emptyNode); setCounter(0, showingTotal); updateSelectAllCheckbox(); return; } await renderRowsChunked(safeRows, prendasTbody, PRENDAS_RENDER_CHUNK, token); updateSelectAllCheckbox(); }; const setSkuSearchMode = (enabled) => { prendasState.isSkuSearch = enabled; updatePaginationUi(); }; const fetchBySku = async (rawInput) => { const rawUpper = safeString(rawInput).trim().toUpperCase(); const normalizedSku = normalizeSku(rawUpper); if (!rawUpper) return null; const directSnap = await getDoc(doc(db, activePrendasPublicCollection, normalizedSku)); const altSnap = directSnap.exists() ? null : await getDoc(doc(db, activePrendasPublicCollection, rawUpper)); const baseSnap = directSnap.exists() ? directSnap : altSnap; if (!baseSnap?.exists()) return null; const item = normalizeDoc(baseSnap); return item; }; const runSkuSearch = async (rawInput) => { const rawUpper = safeString(rawInput).trim().toUpperCase(); if (!rawUpper) return; setSkuSearchMode(true); setLoading(true, "Buscando código..."); try { const result = await fetchBySku(rawUpper); const items = result ? [result] : []; await mergeAdminFields(items); prendasState.filtered = items; prendasState.showingTotal = items.length; prendasState.pageIndex = 0; prendasState.hasNextPage = false; await renderDocs(items); refreshExactCounts(items); } finally { setLoading(false); } }; const ensureRequired = () => { const tipo = safeString(registroProducto.value).trim(); const proveedor = safeString(registroProveedor.value).trim(); const color = safeString(registroColor.value).trim(); const talla = safeString(registroTalla.value).trim(); if (baseMode) { if (!color || !talla) { mostrarErrorRegistro("Completa Color y Talla"); return false; } } else { if (!tipo || !proveedor || !color || !talla) { mostrarErrorRegistro("Completa Proveedor, Producto, Color y Talla"); return false; } } return true; }; const getSelectedName = (selectEl) => { const option = selectEl.options[selectEl.selectedIndex]; return option?.dataset?.nombre || option?.textContent?.split(" - ")[1] || option?.textContent || ""; }; let registroPreview = null; let registroBaseCode = ""; let baseMode = false; let lockedBaseTipo = ""; let lockedBaseProveedor = ""; const parseBaseCode = (codigoValue) => { const raw = safeString(codigoValue).trim().toUpperCase(); if (!raw) return ""; if (raw.includes("/")) { return raw.split("/")[0].trim(); } return raw; }; const parseOrdenFromBaseCode = (baseCode) => { const match = /^HA\d+[A-Z](\d{3,})$/i.exec(safeString(baseCode).trim()); if (!match) return null; const orden = Number(match[1]); return Number.isFinite(orden) ? orden : null; }; const buildModelCode = ({ providerCode, typeCode, seqNumber, colorClave, tallaClave, baseCodeOverride = "" }) => { const baseCode = safeString(baseCodeOverride).trim().toUpperCase() || `HA${providerCode}${typeCode}${String(seqNumber).padStart(3, "0")}`; const codigo = `${baseCode}/${colorClave}-${tallaClave}`; return { baseCode, variantKey: `${colorClave}_${tallaClave}`, codigo }; }; const findCodigoExistente = async (codigoValue) => { const raw = safeString(codigoValue).trim().toUpperCase(); if (!raw) return null; const docId = safeDocId(raw); const candidates = isAdminMode ? [PRENDAS_ADMIN_COLLECTION, activePrendasPublicCollection] : [activePrendasPublicCollection]; for (const collectionName of candidates) { const snap = await getDoc(doc(db, collectionName, docId)); if (snap.exists()) { const data = snap.data() || {}; const codigo = safeString(data.codigo || raw).trim().toUpperCase(); return { codigo, baseCode: parseBaseCode(codigo), data, collectionName }; } } return null; }; const setLocked = (el, locked) => { if (!el) return; el.disabled = Boolean(locked); el.classList.toggle("is-locked", Boolean(locked)); el.setAttribute("aria-disabled", locked ? "true" : "false"); }; const setBaseMode = ({ enabled, tipoValue, provValue, baseValue } = {}) => { baseMode = Boolean(enabled); if (baseMode) { const normalizedTipo = safeString(tipoValue).trim().toUpperCase(); const normalizedProv = safeString(provValue).trim().toUpperCase(); if (baseValue != null && registroBaseCodeInput) registroBaseCodeInput.value = parseBaseCode(baseValue); if (normalizedTipo) registroProducto.value = normalizedTipo; if (normalizedProv) registroProveedor.value = normalizedProv; lockedBaseTipo = normalizedTipo || registroProducto.value; lockedBaseProveedor = normalizedProv || registroProveedor.value; registroBaseCode = parseBaseCode(registroBaseCodeInput?.value || registroBaseCode || baseValue || ""); } else { lockedBaseTipo = ""; lockedBaseProveedor = ""; if (baseValue != null && registroBaseCodeInput) { registroBaseCodeInput.value = safeString(baseValue).trim().toUpperCase(); } registroBaseCode = parseBaseCode(registroBaseCodeInput?.value || ""); } setLocked(registroProducto, baseMode); setLocked(registroProveedor, baseMode); setLocked(registroColor, false); setLocked(registroTalla, false); if (registroDetalles) setLocked(registroDetalles, false); if (registroBaseModeHint) { if (baseMode) { registroBaseModeHint.textContent = "Modo código base activo: Tipo y Proveedor se derivan automáticamente"; registroBaseModeHint.classList.remove("hidden"); } else { registroBaseModeHint.textContent = ""; registroBaseModeHint.classList.add("hidden"); } } }; const getFormPayload = () => { if (!ensureRequired()) return null; const providerCode = safeString(registroProveedor.value).trim().toUpperCase(); const typeCode = safeString(registroProducto.value).trim().toUpperCase(); const colorClave = safeString(registroColor.value).trim().toUpperCase(); const tallaClave = safeString(registroTalla.value).trim().toUpperCase(); const createdByValue = document.getElementById("registro-created-by").value.trim(); const detallesValue = registroDetalles.value.trim(); if (baseMode) { if (!lockedBaseTipo || !lockedBaseProveedor) { mostrarErrorRegistro("El código base no contiene tipo/proveedor válidos."); return null; } if (typeCode !== lockedBaseTipo || providerCode !== lockedBaseProveedor) { mostrarInfoRegistro("Tipo y proveedor se fijan desde el código base reutilizable."); } } return { providerCode: baseMode ? lockedBaseProveedor : providerCode, typeCode: baseMode ? lockedBaseTipo : typeCode, colorClave, tallaClave, createdByValue, detallesValue }; }; const buildRegistroPreview = async () => { const formPayload = getFormPayload(); if (!formPayload) return null; const { providerCode, typeCode, colorClave, tallaClave, createdByValue, detallesValue } = formPayload; const descripcion = detallesValue.trim(); const baseCode = parseBaseCode(registroBaseCodeInput?.value || registroBaseCode); const usingExistingBase = Boolean(baseCode); const { variantKey, codigo } = usingExistingBase ? buildModelCode({ providerCode, typeCode, seqNumber: 1, colorClave, tallaClave, baseCodeOverride: baseCode }) : { baseCode: "", variantKey: `${colorClave}_${tallaClave}`, codigo: `HA${providerCode}${typeCode}XXX/${colorClave}-${tallaClave}` }; return { providerCode, typeCode, colorClave, tallaClave, createdByValue, detallesValue, descripcion, baseCode, variantKey, codigo, usingExistingBase }; }; const handleBuscarCodigoExistente = async () => { const raw = safeString(registroExistingCode?.value).trim().toUpperCase(); if (!raw) { setRegistroStatus("Escribe un código para buscar.", "warning"); return; } try { registroSearchExistingButton.disabled = true; const found = await findCodigoExistente(raw); if (!found) { setBaseMode({ enabled: false, baseValue: "" }); setRegistroStatus("No se encontró el código en la fuente disponible.", "warning"); return; } const tipoBase = safeString(found?.data?.tipo).trim().toUpperCase(); const proveedorBase = safeString(found?.data?.proveedor).trim().toUpperCase(); if (!tipoBase || !proveedorBase) { setBaseMode({ enabled: false }); throw new Error("El código encontrado no tiene tipo/proveedor para bloquear el formulario."); } setBaseMode({ enabled: true, baseValue: found.baseCode, tipoValue: tipoBase, provValue: proveedorBase }); setRegistroStatus(`Código encontrado en ${found.collectionName}. Base reutilizable: ${found.baseCode}`, "success"); } catch (error) { console.error(error); setRegistroStatus("No se pudo buscar el código existente.", "error"); } finally { registroSearchExistingButton.disabled = false; } }; const handleRegistroSubmit = async (event) => { event.preventDefault(); setRegistroStatus(""); try { registroSubmit.disabled = true; registroPreview = await buildRegistroPreview(); if (!registroPreview) return; registroCodigo.textContent = registroPreview.codigo; registroResult.classList.remove("hidden"); setRegistroStatus("Preview generado. Ahora puedes guardar la prenda.", "success"); } catch (error) { const message = error?.message?.includes("contador") || error?.message?.includes("Contador") ? error.message : "No se pudo generar el código. Intenta nuevamente."; setRegistroStatus(message, "error"); console.error(error); } finally { registroSubmit.disabled = false; } }; const handleRegistroSave = async () => { try { const preview = registroPreview || await buildRegistroPreview(); if (!preview) return; registroSave.disabled = true; registroSubmit.disabled = true; const pVenta = toNumber(adminCreatePVentaInput?.value); const buildPublicPayload = ({ codigo, baseCode, variantKey, orden = null, seqNumber = null }) => { const payload = { docId: safeDocId(codigo), codigo, code: codigo, codigoBase: baseCode, baseCode, variantKey, proveedor: preview.providerCode, tipo: preview.typeCode, color: preview.colorClave, colorCode: preview.colorClave, colorName: getSelectedName(registroColor).trim() || preview.colorClave, talla: preview.tallaClave, tallaCode: preview.tallaClave, tallaName: getSelectedName(registroTalla).trim() || preview.tallaClave, descripcion: preview.descripcion || null, detalles: preview.detallesValue || null, orden: Number.isFinite(orden) ? orden : null, seqNumber: Number.isFinite(seqNumber) ? seqNumber : null, status: "ACTIVO", disponibilidad: "DISPONIBLE", isActive: true, creadoPor: preview.createdByValue || null, createdAt: serverTimestamp(), updatedAt: serverTimestamp() }; if (Number.isFinite(pVenta)) { payload.pVenta = pVenta; payload.precioConIva = pVenta; } return payload; }; const reserveNextPublicCode = async (attempts = 5) => { for (let attempt = 0; attempt < attempts; attempt += 1) { const highestSnap = await getDocs( query(collection(db, activePrendasPublicCollection), orderBy("orden", "desc"), limit(1)) ); const highestOrden = Number(highestSnap.docs?.[0]?.data?.()?.orden); const nextOrden = Number.isFinite(highestOrden) ? highestOrden + 1 : 1; const nextCodeData = buildModelCode({ providerCode: preview.providerCode, typeCode: preview.typeCode, seqNumber: nextOrden, colorClave: preview.colorClave, tallaClave: preview.tallaClave }); const nextDocRef = doc(db, activePrendasPublicCollection, safeDocId(nextCodeData.codigo)); const exists = await getDoc(nextDocRef); if (exists.exists()) continue; return { orden: nextOrden, docRef: nextDocRef, ...nextCodeData }; } throw new Error("No se pudo reservar un código único. Intenta nuevamente."); }; if (preview.usingExistingBase) { const docId = safeDocId(preview.codigo); const publicRef = doc(db, activePrendasPublicCollection, docId); const existingSnap = await getDoc(publicRef); if (existingSnap.exists()) { throw new Error("Ese código ya existe en la base pública."); } const orden = parseOrdenFromBaseCode(preview.baseCode); const publicPayload = buildPublicPayload({ codigo: preview.codigo, baseCode: preview.baseCode, variantKey: preview.variantKey, orden, seqNumber: orden }); await setDoc(publicRef, publicPayload); const savedSnap = await getDoc(publicRef); if (!savedSnap.exists()) { throw new Error("No se confirmó el guardado del documento en Firestore."); } console.log("[Registro] Documento guardado:", publicRef.id); } else { const txResult = await reserveNextPublicCode(); const publicPayload = buildPublicPayload({ codigo: txResult.codigo, baseCode: txResult.baseCode, variantKey: txResult.variantKey, orden: txResult.orden, seqNumber: txResult.orden }); await setDoc(txResult.docRef, publicPayload); const savedSnap = await getDoc(txResult.docRef); if (!savedSnap.exists()) { throw new Error("No se confirmó el guardado del documento en Firestore."); } console.log("[Registro] Documento guardado:", txResult.docRef.id); preview.codigo = txResult.codigo; preview.baseCode = txResult.baseCode; } if (adminCreateCostoInput) adminCreateCostoInput.value = ""; if (adminCreatePVentaInput) adminCreatePVentaInput.value = ""; registroPreview = { ...preview }; if (registroBaseCodeInput && preview.baseCode) registroBaseCodeInput.value = preview.baseCode; registroCodigo.textContent = preview.codigo; registroResult.classList.remove("hidden"); await loadPrendasPage({ reset: true, reason: "save-garment" }); setRegistroStatus( preview.usingExistingBase ? `Prenda guardada correctamente: ${safeDocId(preview.codigo)}` : `Prenda guardada correctamente: ${safeDocId(preview.codigo)}`, "success" ); } catch (error) { console.error(error); setRegistroStatus(error?.message || "No se pudo guardar la prenda.", "error"); } finally { registroSave.disabled = false; registroSubmit.disabled = false; } }; const handleRegistroCopy = async () => { try { await navigator.clipboard.writeText(registroCodigo.textContent.trim()); setRegistroStatus("Código copiado al portapapeles.", "success"); } catch (error) { setRegistroStatus("No se pudo copiar el código.", "error"); console.error(error); } }; const handleRegistroReset = () => { registroForm.reset(); registroPreview = null; setBaseMode({ enabled: false, baseValue: "" }); registroResult.classList.add("hidden"); setRegistroStatus(""); }; function initRegistroView() { if (registroInited) return; registroInited = true; if (registroForm) registroForm.addEventListener("submit", handleRegistroSubmit); if (registroSave) registroSave.addEventListener("click", handleRegistroSave); if (registroCopy) registroCopy.addEventListener("click", handleRegistroCopy); if (registroReset) registroReset.addEventListener("click", handleRegistroReset); if (registroSearchExistingButton) registroSearchExistingButton.addEventListener("click", handleBuscarCodigoExistente); if (registroExistingCode) { registroExistingCode.addEventListener("input", () => { const raw = safeString(registroExistingCode.value).trim(); if (!raw) { setBaseMode({ enabled: false, baseValue: "" }); if (registroBaseCodeInput) registroBaseCodeInput.value = ""; } }); } if (registroBaseCodeInput) { registroBaseCodeInput.addEventListener("input", () => { registroBaseCode = parseBaseCode(registroBaseCodeInput.value); if (!registroBaseCode) { setBaseMode({ enabled: false, baseValue: "" }); } }); } } const formatPrice = (value) => { const amount = Number(value); if (!Number.isFinite(amount)) return "$0.00"; return `$${amount.toFixed(2)}`; }; function formatLabel(str) { if (!str) return ""; return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); } const fetchRowBySku = async (skuRaw) => { const sku = safeString(skuRaw).trim().toUpperCase(); if (!sku) return null; const docId = safeDocId(sku); const publicSnap = await getDoc(doc(db, activePrendasPublicCollection, docId)); const publicData = publicSnap.exists() ? publicSnap.data() || {} : null; if (isAdminViewEnabled()) { const adminSnap = await getDoc(doc(db, PRENDAS_ADMIN_COLLECTION, docId)); if (adminSnap.exists()) { const adminData = adminSnap.data() || {}; return { ...(publicData || {}), ...adminData, codigo: safeString(publicData?.codigo || adminData?.codigo || sku).toUpperCase() }; } } if (publicData) { return { ...publicData, codigo: safeString(publicData?.codigo || sku).toUpperCase() }; } return null; }; const buildLabelPrintPages = ({ sku, qty, price, logoSrc }) => { if (!printRoot) return; printRoot.innerHTML = ""; for (let i = 0; i < qty; i += 1) { const page = document.createElement("div"); page.className = "label-page"; const left = document.createElement("div"); left.className = "label-left"; const codeTitle = document.createElement("div"); codeTitle.className = "label-code-title"; codeTitle.textContent = "CODIGO$"; const skuText = document.createElement("div"); skuText.className = "label-sku"; skuText.textContent = sku; const priceTitle = document.createElement("div"); priceTitle.className = "label-price-title"; priceTitle.textContent = "PRECIO$"; const priceValue = document.createElement("div"); priceValue.className = "label-price-value"; priceValue.textContent = price; left.append(codeTitle, skuText, priceTitle, priceValue); const right = document.createElement("div"); right.className = "label-right"; const qrContainer = document.createElement("div"); qrContainer.className = "label-qr"; qrContainer.id = `label-qr-${i}`; const logo = document.createElement("img"); logo.className = "label-logo"; logo.src = logoSrc; logo.alt = "harujagdl"; right.append(qrContainer, logo); page.append(left, right); printRoot.appendChild(page); if (window.QRCode) { new QRCode(qrContainer, { text: sku, width: 140, height: 140, correctLevel: QRCode.CorrectLevel.M }); } } }; const handlePrintLabel = async (event) => { event.preventDefault(); setZplStatus(""); const sku = safeString(labelSkuInput?.value).trim().toUpperCase(); const qtyValue = Number(labelQtyInput?.value); const qty = Number.isInteger(qtyValue) ? qtyValue : 0; if (!sku) { setZplStatus("Ingresa un SKU.", "error"); return; } if (qty < 1 || qty > 200) { setZplStatus("La cantidad debe estar entre 1 y 200.", "error"); return; } if (!printRoot) { setZplStatus("No se encontró el contenedor de impresión (#printRoot).", "error"); return; } try { labelPrintBtn.disabled = true; const row = await fetchRowBySku(sku); if (!row) { setZplStatus("No se encontró el SKU en Firestore.", "error"); return; } const priceRaw = row?.precioVenta ?? row?.pVentaOverride ?? row?.pVenta ?? row?.precio ?? 0; const formattedPrice = formatPrice(priceRaw); const zpl = buildLabelZpl({ sku, price: formattedPrice, qty }); console.log("[label-zpl] ZPL generado:\n", zpl); if (LABEL_ZPL_DEBUG) { try { console.log("[label-zpl-debug] ZPL generado:\n", zpl); } catch (error) { console.error("[label-zpl-debug] No se pudo generar ZPL de depuración:", error); } } buildLabelPrintPages({ sku, qty, price: formattedPrice, logoSrc: getZplLogoDataUrl() }); setZplStatus(`Imprimiendo ${qty} etiqueta(s)…`, "success"); window.print(); } catch (error) { console.error(error); setZplStatus("No se pudo preparar la impresión de etiquetas.", "error"); } finally { labelPrintBtn.disabled = false; } }; function initDbView() { if (dbInited) return; dbInited = true; if (zplForm) zplForm.addEventListener("submit", handlePrintLabel); } // ADMIN DB MODE START const DB_TAB_HASH = "#/codigos"; const isAllowlistedAdmin = (email) => ADMIN_ALLOWLIST.has(safeString(email).trim().toLowerCase()); const getAdminViewEnabled = () => safeStorageGet(window.sessionStorage, ADMIN_VIEW_KEY, "0") === "1"; const isAdminAuthenticated = () => prendasState.adminByAuth === true; const isAdminViewEnabled = () => getAdminViewEnabled() === true && isAdminAuthenticated(); const ensureAdminMode = () => { if (isAdminMode) return true; alert("Activa Modo admin para esta acción."); return false; }; const setAdminViewEnabled = (enabled) => { // Solo permitir modo admin si el usuario está autenticado y en allowlist const adminByAuth = Boolean(prendasState.adminByAuth); const finalEnabled = adminByAuth ? Boolean(enabled) : false; safeStorageSet(window.sessionStorage, ADMIN_VIEW_KEY, finalEnabled ? "1" : "0"); document.body.classList.toggle("is-admin", finalEnabled); }; const setAdminLoginStatus = (message, type = "") => { if (!adminLoginStatus) return; adminLoginStatus.textContent = message; adminLoginStatus.className = `status ${type}`.trim(); }; let adminPanelVisible = false; const setAdminPanelVisible = (visible) => { adminPanelVisible = visible; if (adminPanel) { adminPanel.classList.toggle("hidden", !visible); } if (adminOptionalToggle) { adminOptionalToggle.textContent = visible ? "Ocultar admin" : "Admin"; } }; const applyAdminUI = ({ refresh = true } = {}) => { const byAuth = Boolean(prendasState.adminByAuth); const enabled = byAuth && getAdminViewEnabled(); isAdminMode = enabled; prendasState.isAdmin = enabled; document.body.classList.toggle("is-admin", enabled); if (dbCodigosSection) { dbCodigosSection.classList.toggle("admin-enabled", enabled); } if (adminToggleDb) { adminToggleDb.checked = enabled; adminToggleDb.disabled = !byAuth; } if (adminActions) { adminActions.classList.toggle("hidden", !byAuth); } if (adminLoginButton) { adminLoginButton.classList.toggle("hidden", byAuth); } if (adminLogoutButton) { adminLogoutButton.classList.toggle("hidden", !byAuth); } if (adminLogoutQuickButton) { adminLogoutQuickButton.classList.toggle("hidden", !byAuth); } if (byAuth) { setAdminLoginStatus(`Admin autenticado: ${prendasState.adminEmail}`, "success"); } else { setAdminLoginStatus("Inicia sesión con un correo admin permitido."); setAdminViewEnabled(false); } if (adminCreateFields) { adminCreateFields.classList.toggle("hidden", !enabled); adminCreateFields.setAttribute("aria-hidden", enabled ? "false" : "true"); } if (adminCreateCostoInput) { adminCreateCostoInput.disabled = !enabled; if (!enabled) adminCreateCostoInput.value = ""; } if (adminCreatePVentaInput) { adminCreatePVentaInput.disabled = !enabled; if (!enabled) adminCreatePVentaInput.value = ""; } if (!enabled) { prendasState.adminMap.clear(); closeEditModal(); } setAdminPanelVisible(adminPanelVisible); if (refresh) { loadPrendasPage({ reset: true, reason: "admin-toggle" }); } updateAdminModeBtn(); }; const handleAdminToggle = async () => { // Si no está autenticado como admin, el botón funciona como login if (!prendasState.adminByAuth) { await handleAdminLogin(); if (prendasState.adminByAuth) { setAdminViewEnabled(true); applyAdminUI(); try { await applyFilters(); } catch (_) {} } return; } const nextEnabled = !getAdminViewEnabled(); setAdminViewEnabled(nextEnabled); applyAdminUI(); try { await applyFilters(); } catch (_) {} }; const handleAdminLogin = async () => { if (adminLoginButton) { adminLoginButton.disabled = true; } try { if (isEmbeddedApp) { setAdminLoginStatus("Conectando con Google...", "warning"); } const user = await signInWithGoogleForContext(); if (!user) { return; } const email = safeString(user?.email).trim().toLowerCase(); if (!isAllowlistedAdmin(email)) { setAdminLoginStatus("Tu cuenta no está permitida como admin.", "error"); await signOut(auth); return; } setAdminViewEnabled(true); applyAdminUI(); } catch (error) { console.error(error); setAdminLoginStatus("No se pudo iniciar sesión con Google.", "error"); } finally { if (adminLoginButton) { adminLoginButton.disabled = false; } } }; const handleAdminLogout = async () => { try { isAdminMode = false; await signOut(auth); } catch (error) { console.error(error); } }; // (XLSX import removed) const handleAdminSplitCollections = async () => { if (!ensureAdminMode()) return; if (adminSplitButton) { adminSplitButton.disabled = true; } try { let user = auth.currentUser; if (!user) { if (isEmbeddedApp) { setAdminLoginStatus("Conectando con Google...", "warning"); } user = await signInWithGoogleForContext(); } if (!user) { return; } const email = safeString(user?.email).trim().toLowerCase(); if (!isAllowlistedAdmin(email)) { setAdminLoginStatus("Tu cuenta no está permitida como admin.", "error"); await signOut(auth); return; } const idToken = await user.getIdToken(true); setAdminLoginStatus("Migrando colección base a public/admin por lotes...", "warning"); let totalProcessed = 0; let totalPublic = 0; let totalAdmin = 0; let cursor = ""; let hasMore = true; while (hasMore) { const response = await fetch(SPLIT_FUNCTION_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${idToken}` }, body: JSON.stringify({ batchSize: 300, startAfter: cursor || undefined }) }); const payload = await response.json(); if (!response.ok || !payload?.ok) { throw new Error(payload?.error || `HTTP ${response.status}`); } totalProcessed += Number(payload?.processed || 0); totalPublic += Number(payload?.writtenPublic || 0); totalAdmin += Number(payload?.writtenAdmin || 0); cursor = safeString(payload?.lastDocCursor).trim(); hasMore = Boolean(payload?.hasMore) && Boolean(cursor); setAdminLoginStatus( `Migrando... lote=${payload?.processed || 0}, acumulado=${totalProcessed}, último=${cursor || "fin"}`, "warning" ); if (!payload?.processed) { hasMore = false; } } setAdminLoginStatus( `Migración OK. procesados=${totalProcessed}, public=${totalPublic}, admin=${totalAdmin}.`, "success" ); setPrendasAlert(""); await loadPrendasPage({ reset: true, reason: "migrate-collections" }); } catch (error) { console.error(error); setAdminLoginStatus(`Error al migrar colecciones: ${error.message || error}`, "error"); } finally { if (adminSplitButton) { adminSplitButton.disabled = false; } } }; onAuthStateChanged(auth, async (user) => { const email = safeString(user?.email).trim().toLowerCase(); prendasState.adminUser = user || null; prendasState.adminEmail = email; prendasState.adminByAuth = Boolean(user) && isAllowlistedAdmin(email); if (!prendasState.adminByAuth) { setAdminViewEnabled(false); if (user) { setAdminLoginStatus("Sesión activa sin permisos de admin.", "warning"); } } applyAdminUI(); }); const handleTabChange = (hash) => { if (hash === DB_TAB_HASH) { applyAdminUI({ refresh: false }); } }; if (adminToggleDb) { adminToggleDb.addEventListener("change", handleAdminToggle); } if (adminLoginButton) { adminLoginButton.addEventListener("click", handleAdminLogin); } if (adminLogoutButton) { adminLogoutButton.addEventListener("click", handleAdminLogout); } if (adminLogoutQuickButton) { adminLogoutQuickButton.addEventListener("click", handleAdminLogout); } if (adminOptionalToggle) { adminOptionalToggle.addEventListener("click", () => { setAdminPanelVisible(!adminPanelVisible); }); } const adminModeBtn = document.getElementById("admin-mode-btn"); const updateAdminModeBtn = () => { if (!adminModeBtn) return; const enabled = isAdminViewEnabled(); adminModeBtn.classList.toggle("is-on", enabled); adminModeBtn.setAttribute("aria-pressed", enabled ? "true" : "false"); if (!prendasState.adminByAuth) { adminModeBtn.textContent = "Modo admin"; adminModeBtn.title = "Inicia sesión con Google para ver Costos/Margen/Utilidad"; return; } adminModeBtn.textContent = enabled ? "Salir admin" : "Modo admin"; adminModeBtn.title = enabled ? "Salir de la vista admin" : "Mostrar columnas admin"; }; if (adminModeBtn) { adminModeBtn.addEventListener("click", async () => { try { await handleAdminToggle(); } catch (e) { console.error(e); } }); } if (prendasTable) { prendasTable.addEventListener("click", (event) => { const rowSelect = event.target.closest(".row-select"); if (rowSelect) { const docId = safeString(rowSelect.dataset.docId).trim(); if (!docId) return; if (rowSelect.checked) prendasState.selected.add(docId); else prendasState.selected.delete(docId); updateSelectAllCheckbox(); return; } const editButton = event.target.closest(".rowEditBtn"); if (editButton) { if (!isAdminViewEnabled()) return; const docId = safeString(editButton.dataset.editDoc).trim(); if (!docId) return; const rowData = prendasState.filtered.find((row) => safeString(row.docId || row.id).trim() === docId) || prendasState.baseRows.find((row) => safeString(row.docId || row.id).trim() === docId); const adminData = prendasState.adminMap.get(docId) || {}; openEditModal(rowData ? { ...rowData, ...adminData } : rowData); return; } const deleteButton = event.target.closest(".rowDeleteBtn"); if (!deleteButton) return; if (!isAdminViewEnabled()) return; const docId = safeString(deleteButton.dataset.deleteDoc).trim(); if (!docId) return; const rowData = prendasState.filtered.find((row) => safeString(row.docId || row.id).trim() === docId) || prendasState.baseRows.find((row) => safeString(row.docId || row.id).trim() === docId); handleDeleteRow(rowData).catch((error) => { console.error(error); setPrendasAlert(error?.message || "No se pudo eliminar la prenda.", "error"); }); }); } if (selectAllLabelsCheckbox) { selectAllLabelsCheckbox.addEventListener("change", () => { const checked = Boolean(selectAllLabelsCheckbox.checked); (prendasState.filtered || []).forEach((row) => { const docId = safeString(row.docId || row.id).trim(); if (!docId) return; if (checked) prendasState.selected.add(docId); else prendasState.selected.delete(docId); }); prendasTbody.querySelectorAll(".row-select").forEach((input) => { input.checked = checked; }); updateSelectAllCheckbox(); }); } if (labelsButton && labelsPanel) { labelsButton.addEventListener("click", () => { prendasState.labelsPanelOpen = !prendasState.labelsPanelOpen; labelsPanel.classList.toggle("hidden", !prendasState.labelsPanelOpen); labelsPanel.setAttribute("aria-hidden", prendasState.labelsPanelOpen ? "false" : "true"); }); } labelsScopeInputs.forEach((input) => { input.addEventListener("change", () => { if (input.checked) { prendasState.labelsScope = input.value; } }); }); if (labelsPrintButton) { labelsPrintButton.addEventListener("click", () => { openPrintLabels(prendasState.labelsScope || "page"); }); } if (exportCsvButton) { exportCsvButton.addEventListener("click", () => { handleExportCsv(); }); } if (editPVentaInput) { editPVentaInput.addEventListener("input", updateEditPreview); } if (editCostoInput) { editCostoInput.addEventListener("input", updateEditPreview); } if (editColorInput) { editColorInput.addEventListener("change", updateEditPreview); } if (editTallaInput) { editTallaInput.addEventListener("change", updateEditPreview); } if (editModalCancelButton) { editModalCancelButton.addEventListener("click", closeEditModal); } if (editModalOverlay) { editModalOverlay.addEventListener("click", closeEditModal); } if (editModalSaveButton) { editModalSaveButton.addEventListener("click", saveEdit); } document.addEventListener("keydown", (event) => { if (event.key === "Escape" && editModal && !editModal.classList.contains("hidden")) { closeEditModal(); } }); window.addEventListener("hashchange", () => { handleTabChange(window.location.hash); }); document.querySelectorAll('a.card[href^="#"]').forEach((link) => { link.addEventListener("click", (event) => { const hash = event.currentTarget.getAttribute("href") || ""; handleTabChange(hash); }); }); // ADMIN DB MODE END const handleSearchInput = debounce(async () => { if (isBooting) return; const rawInput = getSearchInput(); if (!rawInput.trim()) { await loadPrendasPage({ reset: true, reason: "search-clear" }); return; } if (isFullSku(rawInput)) { await runSkuSearch(rawInput); return; } setSkuSearchMode(false); await applyFilters(); }, PRENDAS_SEARCH_DEBOUNCE); const handleFilterApply = async () => { if (isBooting) return; const rawInput = getSearchInput(); if (rawInput.trim() && isFullSku(rawInput)) { await runSkuSearch(rawInput); return; } await applyFilters(); }; const handleFilterClear = async () => { if (isBooting) return; setSkuSearchMode(false); clearFiltersUi(); clearHeaderFilters(); await loadPrendasPage({ reset: true, reason: "filters-clear" }); }; // ---- Más filtros (panel) ---- const updateMoreFiltersDot = () => { const hasPriceMin = !!(prendasPrecioMin && String(prendasPrecioMin.value || "").trim()); const hasPriceMax = !!(prendasPrecioMax && String(prendasPrecioMax.value || "").trim()); const hasNoPrice = !!(prendasPrecioSin && prendasPrecioSin.checked); const active = hasPriceMin || hasPriceMax || hasNoPrice; if (moreFiltersDot) moreFiltersDot.style.display = active ? "inline-block" : "none"; }; const setMoreFiltersOpen = (open) => { if (!moreFiltersPanel) return; moreFiltersPanel.style.display = open ? "block" : "none"; }; if (btnMoreFilters) { btnMoreFilters.addEventListener("pointerdown", (e) => { e.preventDefault(); e.stopPropagation(); const isOpen = moreFiltersPanel && moreFiltersPanel.style.display !== "none"; setMoreFiltersOpen(!isOpen); }); } // Cerrar al dar click fuera document.addEventListener("pointerdown", (e) => { if (!moreFiltersPanel || moreFiltersPanel.style.display === "none") return; const t = e.target; if (!(t instanceof HTMLElement)) return; if (t.closest("#moreFiltersPanel") || t.closest("#btnMoreFilters")) return; setMoreFiltersOpen(false); }); // Actualiza dot cuando cambia algo [prendasPrecioMin, prendasPrecioMax].forEach((el) => el && el.addEventListener("input", updateMoreFiltersDot)); if (prendasPrecioSin) prendasPrecioSin.addEventListener("change", updateMoreFiltersDot); if (prendasSearch) prendasSearch.addEventListener("input", handleSearchInput); headerFilterButtons.forEach((button) => { button.addEventListener("pointerdown", (event) => { event.stopPropagation(); const key = button.dataset.openHeaderFilter; if (prendasState.headerPopover?.key === key) { closeHeaderPopover(); return; } renderHeaderFilterPopover(key, button); }); }); document.addEventListener("pointerdown", (event) => { const target = event.target; if (prendasState.headerPopover?.container && !prendasState.headerPopover.container.contains(target)) { closeHeaderPopover(); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeHeaderPopover(); } }); if (prendasPrecioApply) prendasPrecioApply.addEventListener("pointerdown", (event) => { event.preventDefault(); handleFilterApply(); }); if (prendasFiltersClear) { prendasFiltersClear.addEventListener("pointerdown", (event) => { event.preventDefault(); handleFilterClear(); }); } if (prendasYear) { prendasYear.addEventListener("change", () => { syncDateFilterUi(); }); } if (prendasPageSize) { prendasPageSize.addEventListener("change", async () => { if (isBooting) return; await loadPrendasPage({ reset: true, reason: "page-size" }); }); } if (ordenSortBtn) { ordenSortBtn.addEventListener("click", async () => { const current = normalizeSortConfig(prendasState.sort); const nextDir = current.dir === "asc" ? "desc" : "asc"; await setPrendasSort({ key: "orden", dir: nextDir }); }, { passive: true }); } [prendasPrecioMin, prendasPrecioMax].forEach((input) => { if (!input) return; input.addEventListener("keydown", (event) => { if (isBooting) return; if (event.key === "Enter") { event.preventDefault(); handleFilterApply(); } }); }); if (prendasPrev) { prendasPrev.addEventListener("pointerdown", async (event) => { event.preventDefault(); if (isBooting) return; const prevIndex = Math.max(prendasState.pageIndex - 1, 0); await loadPrendasPage({ pageIndex: prevIndex, reason: "pagination-prev" }); }); } if (prendasNext) { prendasNext.addEventListener("pointerdown", async (event) => { event.preventDefault(); if (isBooting) return; const nextIndex = prendasState.pageIndex + 1; await loadPrendasPage({ pageIndex: nextIndex, reason: "pagination-next" }); }); } const init = async () => { isBooting = true; debugLog("inicio app"); resetPrendasState(); updateOrdenSortToggleUi(); resetCounters(); populateYearFilter(); populateMonthFilter(); clearFiltersUi(); try { if (isEmbeddedApp) { setAdminLoginStatus("Conectando...", "warning"); } debugLog("init firebase/auth"); const redirectUser = await authRedirectResultPromise; if (redirectUser) { const email = safeString(redirectUser?.email).trim().toLowerCase(); if (isAllowlistedAdmin(email)) { setAdminLoginStatus(`Admin autenticado: ${email}`, "success"); setAdminViewEnabled(true); } else { setAdminLoginStatus("Sesión iniciada, pero sin permisos de admin.", "warning"); await signOut(auth); } } setRegistroStatus("Cargando diccionario..."); registroSubmit.disabled = true; setSelectLoading(registroProducto, "Cargando..."); setSelectLoading(registroProveedor, "Cargando..."); setSelectLoading(registroColor, "Cargando..."); setSelectLoading(registroTalla, "Cargando..."); debugLog("cargando diccionario"); const dictionary = await loadDictionary(); const dictionaryReady = !dictionary.empty && dictionary.missing.length === 0; if (dictionary.empty) { setRegistroStatus("Diccionario vacío: corre el workflow Seed Firestore.", "error"); } else if (dictionary.missing.length) { setRegistroStatus( `Faltan datos en: ${dictionary.missing.join(", ")}.`, "error" ); } else { setRegistroStatus(""); } if (dictionary.tipos.length) { fillDictionarySelect( registroProducto, dictionary.tipos.sort((a, b) => a.clave.localeCompare(b.clave)) ); } else { setSelectLoading(registroProducto, "Sin opciones disponibles"); } if (dictionary.proveedores.length) { fillDictionarySelect( registroProveedor, dictionary.proveedores.sort((a, b) => a.clave.localeCompare(b.clave)) ); } else { setSelectLoading(registroProveedor, "Sin opciones disponibles"); } if (dictionary.colores.length) { const sortedColores = dictionary.colores.sort((a, b) => a.clave.localeCompare(b.clave)); prendasState.dictEntries.colores = sortedColores; fillDictionarySelect( registroColor, sortedColores ); fillEditSelect(editColorInput, sortedColores); } else { setSelectLoading(registroColor, "Sin opciones disponibles"); } loadSalesMiniDashboard(); if (dictionary.tallas.length) { const sortedTallas = dictionary.tallas.sort((a, b) => a.clave.localeCompare(b.clave)); prendasState.dictEntries.tallas = sortedTallas; fillDictionarySelect( registroTalla, sortedTallas ); fillEditSelect(editTallaInput, sortedTallas); } else { setSelectLoading(registroTalla, "Sin opciones disponibles"); } registroSubmit.disabled = !dictionaryReady; } catch (error) { setRegistroStatus("No pudimos cargar el diccionario.", "error"); registroSubmit.disabled = true; console.error(error); alert(`Error cargando diccionario: ${error?.message || error}`); } finally { // ADMIN DB MODE START setAdminPanelVisible(false); applyAdminUI(); handleTabChange(window.location.hash); // ADMIN DB MODE END setSkuSearchMode(false); let prendasCollection = PRENDAS_PUBLIC_COLLECTION; try { prendasCollection = await resolvePrendasCollection(); } catch (error) { console.error("[Prendas] No se pudo resolver colección, usando default", error); } setPrendasCollections({ publicCollection: prendasCollection }); try { debugLog("fetch prendas base"); const baseSnapshot = await getDocs(query(prendasPublicRef, limit(10))); console.log("[Prendas] Base query (sin filtros) docs", { collection: activePrendasPublicCollection, docs: baseSnapshot.size }); console.log("[Prendas] snapshot size:", baseSnapshot.size); debugState.snapshotSizes.initProbe = baseSnapshot.size; updateDebugPanel(); if (baseSnapshot.empty) { setPrendasAlert("Aún no se ha migrado la colección public. Entra a Admin y corre ‘Migrar a public/admin’."); } } catch (error) { console.error("[Prendas] ❌ No se pudo leer la colección pública", error); setPrendasAlert("No se encontró la colección pública o las reglas de Firestore la están bloqueando."); return; } try { if (typeof loadMetadataCounts === "function") { const total = await loadMetadataCounts(); if (Number.isFinite(total) && !hasActiveFilters(getCurrentFilters())) { prendasState.showingTotal = total; setCounter(prendasState.filtered.length, total); } } else { console.warn("loadMetadataCounts() no existe, se omite."); } } catch (error) { console.warn("[Prendas] loadMetadataCounts falló", error); } try { console.log("query filters", getCurrentFilters()); console.log("baseRows", prendasState.baseRows?.length ?? 0, "filtered", prendasState.filtered?.length ?? 0); debugLog("render routes/prendas init"); await loadPrendasPage({ reset: true, reason: "init" }); console.log("baseRows", prendasState.baseRows?.length ?? 0, "filtered", prendasState.filtered?.length ?? 0); } finally { prendasState.isLoadingInitial = false; setLoading(false); isBooting = false; } } }; const setupClarity = () => { const clarityId = safeString(window.HARUJA_CLARITY_ID).trim(); if (!clarityId || isEmbeddedApp) return; const script = document.createElement("script"); script.async = true; script.src = `https://www.clarity.ms/tag/${encodeURIComponent(clarityId)}`; script.referrerPolicy = "no-referrer-when-downgrade"; document.head.appendChild(script); }; document.addEventListener("DOMContentLoaded", () => { if (debugMode) { const panel = document.createElement("aside"); panel.id = "debug-panel"; panel.className = "debug-panel"; document.body.appendChild(panel); } setupClarity(); init(); navigateTo(getViewFromUrl(), { replace: true }); updateDebugPanel(); });