MC
Micarla Camilo Assessoria Moda Fácil
🔍
Carregando anúncios...
🔍
Carregando anúncios...

🏢 Painel de Fabricantes

Acompanhe o total de peças por anúncio ativo para cada fabricante.

Carregando painel...
🔍
Carregando pedidos...
Lista de Compras — ${esc(sup.name)}
Lista de Pedidos para o Fabricante
${esc(sup.name)}
${new Date().toLocaleDateString('pt-BR')}
Micarla Camilo Assessoria
Total Geral da Compra
Valor a pagar ao fornecedor (sem taxa)
${totalPcs} PEÇAS
R$ ${totalValue.toFixed(2).replace('.',',')}
${contentHtml}
`; } // Conteúdo interno para o PDF (inline styles puros, sem tags html/body) function buildSupplierPdfInner(sup, contentHtml, totalPcs, totalValue = 0) { const FNT = "font-family:Arial,Helvetica,sans-serif;"; return `
Lista de Pedidos para o Fabricante
${esc(sup.name)}
${new Date().toLocaleDateString('pt-BR')}
Micarla Camilo Assessoria
Total Geral da Compra
Valor a pagar ao fornecedor (sem taxa)
${totalPcs} PEÇAS
R$ ${totalValue.toFixed(2).replace('.',',')}
${contentHtml}
Gerado em ${new Date().toLocaleString('pt-BR')} por Moda Fácil — ERP de Assessoria
`; } async function doPrintSupplierList(supplierId) { const data = await buildSupplierContent(supplierId); if (!data) return; const { sup, contentHtml, totalPcs, totalValue } = data; const win = window.open('', '_blank', 'width=800,height=900'); win.document.write(buildSupplierListHtml(sup, contentHtml, totalPcs, totalValue)); win.document.close(); window.openClientEditModal = openClientEditModal; window.deleteSupplierTab = deleteSupplierTab; window.openSupplierModalTab = openSupplierModalTab; window.openPrintSupplierModal = openPrintSupplierModal; } // ── Carrega jsPDF + html2canvas sob demanda ─────────────────────────── async function loadPdfLibs() { await Promise.all([ new Promise((res, rej) => { if (window.html2canvas) return res(); const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; s.onload = res; s.onerror = rej; document.head.appendChild(s); }), new Promise((res, rej) => { if (window.jspdf) return res(); const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'; s.onload = res; s.onerror = rej; document.head.appendChild(s); }), ]); } // ── Gera PDF em base64 a partir do conteúdo do fornecedor ──────────── async function generateSupplierPdfBase64(supplierId) { const data = await buildSupplierContent(supplierId); if (!data) return null; const { sup, contentHtml, totalPcs, totalValue } = data; toast('Gerando PDF…', 'info'); await loadPdfLibs(); // A4 a 96dpi = 794px; usamos exatamente essa largura sem padding extra const A4_PX = 794; const container = document.createElement('div'); container.style.cssText = `position:fixed;left:-9999px;top:0;width:${A4_PX}px;background:#fff;box-sizing:border-box;overflow:hidden;`; container.innerHTML = buildSupplierPdfInner(sup, contentHtml, totalPcs, totalValue); document.body.appendChild(container); try { const canvas = await window.html2canvas(container, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', logging: false, width: A4_PX, windowWidth: A4_PX, }); document.body.removeChild(container); const { jsPDF } = window.jspdf; const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true }); const A4_W = 210, A4_H = 297; // Altura proporcional ao conteúdo capturado const imgH = (canvas.height / canvas.width) * A4_W; const imgData = canvas.toDataURL('image/jpeg', 0.88); let posY = 0, page = 0; while (posY < imgH) { if (page > 0) pdf.addPage(); // Posiciona imagem de forma que a fatia correta apareça em cada página pdf.addImage(imgData, 'JPEG', 0, -posY, A4_W, imgH); posY += A4_H; page++; } return { pdf, sup, base64: pdf.output('datauristring') }; } catch (e) { if (document.body.contains(container)) document.body.removeChild(container); throw e; } } // ── Baixar PDF ──────────────────────────────────────────────────────── async function downloadSupplierPdf(supplierId) { try { const result = await generateSupplierPdfBase64(supplierId); if (!result) return; const { pdf, sup } = result; const filename = `Lista-${sup.name.replace(/[^a-zA-Z0-9À-ú ]/g, '')}-${new Date().toLocaleDateString('pt-BR').replace(/\//g, '-')}.pdf`; pdf.save(filename); toast('PDF baixado com sucesso!', 'success'); } catch (e) { toast('Erro ao gerar PDF: ' + e.message, 'error'); } } // ── Enviar PDF via WhatsApp ─────────────────────────────────────────── let _pendingPdfSupplierId = null; function openWaPhoneModal(supplierId) { _pendingPdfSupplierId = supplierId; document.getElementById('inputWaPhone').value = ''; openModal('modalWaPhone'); } document.getElementById('btnConfirmSendWa').addEventListener('click', async () => { const phone = document.getElementById('inputWaPhone').value.trim(); if (!phone) { toast('Digite o número', 'warning'); return; } const supplierId = _pendingPdfSupplierId; closeModal('modalWaPhone'); try { const result = await generateSupplierPdfBase64(supplierId); if (!result) return; const { base64, sup } = result; const filename = `Lista-${sup.name.replace(/[^a-zA-Z0-9À-ú ]/g, '')}.pdf`; toast('Enviando para WhatsApp…', 'info'); await post('/wa/send-supplier-pdf', { phone, pdfBase64: base64, filename, supplierName: sup.name }); toast(`PDF enviado para ${phone}!`, 'success'); } catch (e) { toast('Erro ao enviar: ' + e.message, 'error'); } }); window.openSupplierModalTab = openSupplierModalTab; window.deleteSupplierTab = deleteSupplierTab; // ── Collage: agrupa múltiplas imagens num único mosaico ─────────────────── function createImageCollage(base64Array, size = 1080, gap = 8) { return new Promise(async (resolve) => { if (!base64Array || base64Array.length === 0) { resolve(null); return; } if (base64Array.length === 1) { resolve(base64Array[0]); return; } const imgs = await Promise.all(base64Array.map(src => new Promise((res) => { const img = new Image(); img.onload = () => res(img); img.onerror = () => res(null); img.src = src; }))); const valid = imgs.filter(Boolean); if (valid.length === 0) { resolve(base64Array[0]); return; } const n = valid.length; let cols, rows; if (n === 2) { cols = 2; rows = 1; } else if (n === 3) { cols = 3; rows = 1; } else if (n <= 4) { cols = 2; rows = 2; } else if (n <= 6) { cols = 3; rows = 2; } else { cols = 3; rows = Math.ceil(n / 3); } const radius = 14; const cellW = Math.floor((size - gap * (cols + 1)) / cols); const cellH = cellW; const canvasH = gap + rows * (cellH + gap); const cvs = document.createElement('canvas'); cvs.width = size; cvs.height = canvasH; const ctx = cvs.getContext('2d'); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, size, canvasH); valid.forEach((img, i) => { const col = i % cols; const row = Math.floor(i / cols); const x = gap + col * (cellW + gap); const y = gap + row * (cellH + gap); // Clip com cantos arredondados ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, cellW, cellH, radius); ctx.clip(); // Cover crop centralizado const scale = Math.max(cellW / img.width, cellH / img.height); const sw = cellW / scale; const sh = cellH / scale; const sx = (img.width - sw) / 2; const sy = (img.height - sh) / 2; ctx.drawImage(img, sx, sy, sw, sh, x, y, cellW, cellH); // Gradiente sutil na base para indicar que é clicável const grad = ctx.createLinearGradient(x, y + cellH * 0.6, x, y + cellH); grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(1, 'rgba(0,0,0,0.25)'); ctx.fillStyle = grad; ctx.fillRect(x, y, cellW, cellH); ctx.restore(); }); resolve(cvs.toDataURL('image/jpeg', 0.92)); }); } // ── Gerenciar Fotos ─────────────────────────────────────────────────────── function onPhotosSelected(e) { const files = Array.from(e.target.files); files.forEach(file => { if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = async ev => { const compressed = await compressImage(ev.target.result, 1024, 0.8); _annPhotos.push(compressed); renderPhotoPreviews(); }; reader.readAsDataURL(file); }); e.target.value = ''; // reset input para permitir re-seleção } function renderPhotoPreviews() { const wrap = document.getElementById('annPhotosWrap'); const placeholder = document.getElementById('annPhotosPlaceholder'); if (_annPhotos.length === 0) { placeholder.style.display = ''; wrap.querySelectorAll('.photo-thumb-wrap').forEach(el => el.remove()); return; } placeholder.style.display = 'none'; wrap.querySelectorAll('.photo-thumb-wrap').forEach(el => el.remove()); _annPhotos.forEach((src, idx) => { const div = document.createElement('div'); div.className = 'photo-thumb-wrap'; div.innerHTML = ``; div.querySelector('.photo-thumb-del').addEventListener('click', (e) => { e.stopPropagation(); _annPhotos.splice(idx, 1); renderPhotoPreviews(); }); wrap.appendChild(div); }); } // ── Carregar Instâncias ─────────────────────────────────────────────────── async function loadInstances() { try { const data = await get('/whatsapp/instances'); const sel = document.getElementById('annInstance'); sel.innerHTML = ''; (data.instances || []).forEach(inst => { const opt = document.createElement('option'); opt.value = inst.id; opt.textContent = inst.name + (inst.status === 'open' ? ' ✅' : ' ⚠️'); sel.appendChild(opt); }); } catch (e) { console.warn('Instâncias WA:', e.message); } } async function onInstanceChange() { const instId = document.getElementById('annInstance').value; const groupSel = document.getElementById('annGroup'); groupSel.innerHTML = ''; groupSel.disabled = true; if (!instId) { groupSel.innerHTML = ''; return; } try { const data = await get(`/whatsapp/instances/${instId}/groups`); groupSel.innerHTML = ''; (data.groups || []).forEach(g => { const opt = document.createElement('option'); opt.value = g.wa_group_id; opt.textContent = g.name || g.wa_group_id; opt.dataset.name = g.name || ''; groupSel.appendChild(opt); }); groupSel.disabled = false; } catch { groupSel.innerHTML = ''; } } function updatePricePreview() { const price = parseFloat(document.getElementById('annPrice').value) || 0; const fee = parseFloat(document.getElementById('annFee').value) || 13; const isGrade24 = document.getElementById('annGrade24')?.checked; const preview = document.getElementById('pricePreview'); if (price > 0) { const withFee = price * (1 + fee / 100); document.getElementById('pricePerPiece').textContent = money(withFee); document.getElementById('priceFullLabel').textContent = isGrade24 ? 'Grade 24 completa:' : ''; document.getElementById('priceFull').textContent = isGrade24 ? money(withFee * 24) : 'Venda livre'; preview.style.display = ''; } else { preview.style.display = 'none'; } } function timeAgo(ts) { if (!ts) return ''; const d = ts.toDate ? ts.toDate() : new Date(ts._seconds ? ts._seconds * 1000 : ts); const diff = Math.floor((Date.now() - d.getTime()) / 1000); if (diff < 60) return 'agora mesmo'; if (diff < 3600) return `há ${Math.floor(diff/60)}min`; if (diff < 86400) return `há ${Math.floor(diff/3600)}h`; return `há ${Math.floor(diff/86400)}d`; } async function toggleGroupMonitor(groupDocId, enable) { try { const token = localStorage.getItem('token') || ''; await fetch(`/api/whatsapp/groups/${groupDocId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ monitor_polls: enable }), }); } catch { toast('Erro ao salvar configuração', 'error'); } } async function createAnnFromPoll(poll, waGroupId, instanceId, groupName, btn) { btn.disabled = true; btn.textContent = '⏳'; try { const isPacoteGroup = (groupName || '').toUpperCase().includes('PACOTE'); const { announcement } = await post('/announcements', { product_name: poll.question || 'Anúncio', sale_price: 0, fee_percent: 0, target_qty: 0, wa_instance_id: instanceId, wa_group_id: waGroupId, wa_group_name: groupName, wa_poll_id: poll.wa_poll_id, }); // Auto-buscar fotos recentes do grupo e salvar no anúncio btn.textContent = '📷'; try { const params = new URLSearchParams({ instance_id: instanceId, group_jid: waGroupId, limit: 6 }); const imgData = await get(`/whatsapp/group-images?${params}`); if (imgData.images?.length) { await patch(`/announcements/${announcement.id}`, { images: imgData.images, send_to_wa: false }); toast(`✅ Anúncio criado com ${imgData.images.length} foto(s) detectadas!`); } else { toast('✅ Anúncio criado!'); } } catch { toast('✅ Anúncio criado!'); } btn.textContent = '✅'; btn.style.color = '#4ade80'; await loadAtivos(); } catch (e) { btn.disabled = false; btn.textContent = '➕'; toast('Erro: ' + e.message, 'error'); } } async function loadGroupPolls(waGroupId, instanceId, groupName, container) { container.innerHTML = '
Carregando...
'; try { const { polls } = await get(`/announcements/detected-polls?groupId=${encodeURIComponent(waGroupId)}`); polls.forEach(p => { if (p.question) p.question = stripEmojis(p.question); }); const header = `
${polls.length} enquete(s)
`; if (!polls.length) { container.innerHTML = header + `
Nenhuma enquete detectada neste grupo.
`; } else { container.innerHTML = header + polls.map(p => `
${timeAgo(p.detected_at)} 📊 ${esc(p.question)}
`).join(''); container.querySelectorAll('.btn-create-ann').forEach(btn => { btn.addEventListener('click', async () => { const row = btn.closest('.poll-item-row'); const poll = JSON.parse(row.dataset.poll); await createAnnFromPoll(poll, waGroupId, instanceId, groupName, btn); }); }); } container.querySelector('.btn-refresh-polls')?.addEventListener('click', () => loadGroupPolls(waGroupId, instanceId, groupName, container)); container.querySelector('.btn-close-polls')?.addEventListener('click', () => { container.style.display = 'none'; container.closest('[data-group-block]')?.querySelector('.btn--view-polls')?.style.setProperty('color','var(--text-muted)'); }); } catch { container.innerHTML = '
Erro ao carregar enquetes
'; } } async function renderGroupsModal(groups) { const body = document.getElementById('newAnnBody'); if (!groups.length) { body.innerHTML = `
📱
Nenhum grupo cadastrado
Sincronize os grupos na seção WhatsApp.
`; return; } body.innerHTML = `
Ative os grupos que devem gerar anúncios automaticamente ao criar enquetes.
` + groups.map(g => { const isOn = !!g.monitor_polls; const gname = esc(g.name || g.alias || g.wa_group_id || 'Grupo'); return `
${gname}
`; }).join(''); body.querySelectorAll('.grp-toggle').forEach(btn => { btn.addEventListener('click', async () => { const isOn = btn.classList.toggle('grp-on'); btn.style.background = isOn ? '#6366f1' : 'rgba(255,255,255,.15)'; btn.querySelector('span').style.cssText = `position:absolute;top:3px;${isOn?'right':'left'}:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:all .2s`; await toggleGroupMonitor(btn.dataset.gid, isOn); if (isOn) toast('✅ Grupo ativado — anúncios criados automaticamente'); }); }); body.querySelectorAll('.btn--view-polls').forEach(btn => { btn.addEventListener('click', async () => { const block = btn.closest('[data-group-block]'); const panel = block.querySelector('.polls-panel'); panel.style.display = ''; btn.style.color = '#6366f1'; await loadGroupPolls(btn.dataset.waid, btn.dataset.instid, btn.dataset.gname, panel); }); }); } async function openNewAnnModal() { const body = document.getElementById('newAnnBody'); body.innerHTML = '
Carregando grupos...
'; openModal('modalNewAnn'); try { const data = await get('/whatsapp/groups/all'); // Filtra apenas grupos com is_active=1 (a escolha do owner em mf_wa_groups) const groups = (data.groups || []).filter(g => g.is_active === 1 || g.is_active === true); await renderGroupsModal(groups); } catch { body.innerHTML = '
Erro ao carregar grupos
'; } } // ── ABA: ANÚNCIOS ATIVOS ───────────────────────────────────────────────── // Acha o card do anúncio com este seq (em qualquer tab visível) e dá um // outline pulsando + scrollIntoView pra usuário identificar visualmente. function highlightAnnCard(seq) { let annId = null; const inAtivos = (window._cachedAtivos || []).find(a => Number(a.seq) === seq); const inFechados = (_lastFechadosData?.lots || []).find(l => Number(l.seq) === seq); const inPedidos = (_lastPedidosData?.lots || []).find(l => Number(l.seq) === seq); annId = inAtivos?.id || inFechados?.announcement_id || inPedidos?.announcement_id; if (!annId) return; const card = document.querySelector(`[data-ann-id="${CSS.escape(annId)}"]`); if (!card) return; card.scrollIntoView({ behavior: 'smooth', block: 'center' }); card.classList.add('ann-card--highlight'); setTimeout(() => card.classList.remove('ann-card--highlight'), 2400); } // Localiza o tab que contém o anúncio com este seq (cache + 1 fetch de fechados // se faltar). Se for em outro tab, pula pra ele e deixa o filtro ativo mostrando // o card. Não abre nenhum modal — usuário clica no card se quiser entrar. async function switchToTabWithSeq(seq) { const activeTab = document.querySelector('.tab-btn.active')?.dataset.tab; const inAtivos = (window._cachedAtivos || []).find(a => Number(a.seq) === seq); if (inAtivos) { if (activeTab !== 'ativos') switchTab('ativos'); return true; } // pré-fetch one-shot de fechados pra cobrir o caso comum if (!_lastFechadosData) { try { _lastFechadosData = await get('/announcements/fechados'); } catch {} } const inFechados = (_lastFechadosData?.lots || []).find(l => Number(l.seq) === seq); if (inFechados) { if (activeTab !== 'fechados') switchTab('fechados'); return true; } const inPedidos = (_lastPedidosData?.lots || []).find(l => Number(l.seq) === seq); if (inPedidos) { if (activeTab !== 'pedidos') switchTab('pedidos'); return true; } return false; } async function loadAtivos(isBackground = false) { const grid = document.getElementById('annGridAtivos'); const header = document.getElementById('ativoGroupHeader'); try { // 1. CARREGAMENTO: se já temos _lastFullAnns em cache, usa ele direto (mais rápido + completo). // Senão, busca só o dia mais recente (latest_only=true) pra renderizar rápido. let anns; let needsBackgroundLoad = false; if (_lastFullAnns && _lastFullAnns.length > 0) { anns = _lastFullAnns; await loadSuppliers(); } else { const [data] = await Promise.all([ get('/announcements?status=active&latest_only=true'), loadSuppliers(), ]); anns = data.announcements || []; needsBackgroundLoad = true; } // 2. CARREGAMENTO EM SEGUNDO PLANO: full set (só na primeira vez) if (!isBackground && needsBackgroundLoad) { setTimeout(async () => { try { const fullData = await get('/announcements?status=active'); const allAnns = fullData.announcements || []; if (allAnns.length > anns.length) { console.log(`[Performance] Carregados mais ${allAnns.length - anns.length} anúncios em background.`); _lastFullAnns = allAnns; // Reload usando _lastFullAnns como source — vai re-renderizar calendário E cards corretos loadAtivos(true); } } catch (e) { console.warn("Background load failed", e); } }, 2000); } // Backend já filtra por mf_wa_groups.is_active=1 — não precisa filtrar por nome aqui. anns.forEach(a => { if (a.product_name) a.product_name = stripEmojis(a.product_name); if (a.all_intents) a.all_intents.forEach(i => { if (i.customer_name) i.customer_name = stripEmojis(i.customer_name); }); }); // Cacheia ativos pra busca cross-tab encontrar resultados sem refetch window._cachedAtivos = anns; // Rastreamento de celebração — só depois do primeiro carregamento completo const newlyComplete = []; anns.forEach(ann => { const prev = prevQtyMap.get(ann.id) ?? -1; const prevClosed = prevClosedRoundsMap.get(ann.id) ?? 0; const currentClosed = ann.closed_rounds || 0; const tgt = ann.target_qty || 0; if (_ativosInitialized && prev >= 0 && tgt > 0 && ann.qty_total > prev && Math.floor(ann.qty_total / tgt) > Math.floor(prev / tgt)) { const milestone = Math.floor(ann.qty_total / tgt) * tgt; newlyComplete.push({ id: ann.id, name: ann.product_name || 'Anúncio', milestone }); } else if (_ativosInitialized && prev >= 0 && currentClosed > prevClosed) { newlyComplete.push({ id: ann.id, name: ann.product_name || 'Anúncio', milestone: null }); } prevQtyMap.set(ann.id, ann.qty_total); prevClosedRoundsMap.set(ann.id, currentClosed); }); _ativosInitialized = true; // Validar se o grupo salvo ainda existe nos dados atuais if (selectedGroupId !== null) { const validIds = new Set(anns.map(a => a.wa_group_id || '__sem_grupo__')); if (!validIds.has(selectedGroupId)) { selectedGroupId = null; sessionStorage.removeItem('mf_selected_group'); } } if (selectedGroupId === null) { // ── Tela de seleção de grupos ────────────────────────────────────── header.hidden = true; header.innerHTML = ''; if (!anns.length) { grid.innerHTML = `
📢

Nenhum anúncio ativo.

`; return; } // Agrupar anúncios por grupo const groupsMap = {}; anns.forEach(ann => { const gid = ann.wa_group_id || '__sem_grupo__'; if (!groupsMap[gid]) groupsMap[gid] = { id: gid, name: ann.wa_group_name || ann.wa_group_id || 'Sem grupo', anns: [] }; groupsMap[gid].anns.push(ann); }); const groups = Object.values(groupsMap); if (groups.length === 1) { // Único grupo: selecionar automaticamente sem chamar loadAtivos() de novo selectedGroupId = groups[0].id; // fall through para renderizar o grupo logo abaixo } else { // Mostrar seletor de grupos grid.innerHTML = groups.map(g => renderGroupCard(g)).join(''); grid.querySelectorAll('[data-select-group]').forEach(card => { card.addEventListener('click', () => { selectedGroupId = card.dataset.selectGroup; sessionStorage.setItem('mf_selected_group', selectedGroupId); loadAtivos(); }); }); return; // aguardar clique do usuário } } // ── Tela de anúncios do grupo selecionado ─────────────────────────── { let filtered = anns.filter(a => (a.wa_group_id || '__sem_grupo__') === selectedGroupId); if (_annSearchQuery) { const q = _annSearchQuery.toLowerCase().trim(); filtered = filtered.filter(a => (a.product_name || '').toLowerCase().includes(q) || (a.seq && `#${a.seq}`.toLowerCase().includes(q)) || (a.seq && String(a.seq).includes(q)) || (a.sale_price && String(a.sale_price).includes(q)) || (a.notes || '').toLowerCase().includes(q) ); } _annDatesMap.clear(); filtered.forEach(a => _annDatesMap.set(a.id, a.created_at)); // Filtro pela JANELA (metade do mês anterior + mês atual). Em meses // passados (escolhidos no seletor) vira mês-único — ver _calWindowRange. const _win = _calWindowRange(); const _winStart = _win.start.getTime(), _winEnd = _win.end.getTime(); filtered = filtered.filter(a => { const t = new Date(a.created_at).getTime(); return t >= _winStart && t <= _winEnd; }); // Mapeia dias (CHAVE y-m-d) que possuem anúncios dentro da janela. // FIX (Micarla 24/05 sumiu): usa _lastFullAnns se disponível (full set carregado // em background). Sem isso, calendário mostraria só dias do "latest_only=true". _activeDaysSet.clear(); const sourceForCalendar = (_lastFullAnns && _lastFullAnns.length > 0) ? _lastFullAnns.filter(a => (a.wa_group_id || '__sem_grupo__') === selectedGroupId) : filtered; sourceForCalendar.forEach(a => { const t = new Date(a.created_at).getTime(); if (t >= _winStart && t <= _winEnd) _activeDaysSet.add(_dkeyOf(a.created_at)); }); // 1º carregamento: se o dia de hoje não tem anúncios, seleciona o dia // mais recente que tem — fica destacado em verde, como se fosse clicado. if (_ativosFirstLoad) { _ativosFirstLoad = false; if (_calendarDay !== null && !_activeDaysSet.has(_calendarDay)) { const keys = [..._activeDaysSet]; _calendarDay = keys.length ? keys.sort((a, b) => _dkeyToDate(a) - _dkeyToDate(b)).pop() : null; } } // Filtro estrito pelo dia selecionado (null = mês inteiro). // _activeDaysSet é montado ANTES deste filtro, então a barra de dias // continua mostrando todos os dias com anúncio (clicáveis). // Quando a busca está ativa, ignora o filtro de dia (busca em todos os dias). if (_calendarDay !== null && !_annSearchQuery) { filtered = filtered.filter(a => _dkeyOf(a.created_at) === _calendarDay); } const groupName = filtered[0]?.wa_group_name || anns.find(a => (a.wa_group_id || '__sem_grupo__') === selectedGroupId)?.wa_group_name || selectedGroupId; // Só mostrar botão voltar se houver mais de 1 grupo const totalGroups = new Set(anns.map(a => a.wa_group_id || '__sem_grupo__')).size; const semFotos = filtered.filter(a => !a.images_count || a.images_count === 0).length; header.hidden = false; header.innerHTML = ` ${totalGroups > 1 ? `
${esc(groupName)}
` : ''}
`; { const _c = document.getElementById('tabCountAtivos'); if (_c) _c.textContent = filtered.length || ''; } document.getElementById('btnBackGroups')?.addEventListener('click', () => { selectedGroupId = null; sessionStorage.removeItem('mf_selected_group'); loadAtivos(); }); // Renderiza o calendário renderCalendar(isBackground); if (!filtered.length) { const _selDate = _calendarDay ? _dkeyToDate(_calendarDay) : null; const emptyMsg = (_annSearchQuery && _annSearchQuery.trim()) ? `Nenhum anúncio ativo encontrado para "${esc(_annSearchQuery.trim())}".` : _selDate ? `Nenhum anúncio no dia ${String(_selDate.getDate()).padStart(2, '0')}/${String(_selDate.getMonth() + 1).padStart(2, '0')}.` : 'Nenhum anúncio ativo neste grupo.'; grid.innerHTML = `
📢

${emptyMsg}

`; return; } // Ordenar conforme seleção do usuário if (_annSortOrder === 'maior') filtered.sort((a, b) => (b.qty_total || 0) - (a.qty_total || 0) || new Date(b.created_at) - new Date(a.created_at)); else if (_annSortOrder === 'menor') filtered.sort((a, b) => (a.qty_total || 0) - (b.qty_total || 0) || new Date(b.created_at) - new Date(a.created_at)); else if (_annSortOrder === 'recente') filtered.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); else if (_annSortOrder === 'antigo') filtered.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); // Só re-renderiza se dados mudaram (ordenar por ID para assinatura estável) // Inclui campos editáveis (target_qty, sale_price, fee_percent, supplier_id, notes) // pra forçar re-render quando o user edita pelo modal Editar Anúncio. const newSig = [...filtered].sort((a,b) => a.id < b.id ? -1 : 1) .map(a => `${a.id}:${a.qty_total}:${a.all_intents?.length}:${a.round}:${a.status}:${a.images_count}:${a.target_qty}:${a.sale_price}:${a.fee_percent}:${a.supplier_id||''}:${(a.notes||'').length}`).join('|'); if (newSig === _lastAnnsSignature && _ativosInitialized) return; _lastAnnsSignature = newSig; // Salva posição de scroll antes de re-renderizar const oldScroll = grid.scrollTop; const oldWindowScroll = window.scrollY; let html = ''; let lastDayKey = null; filtered.forEach(ann => { const date = new Date(ann.created_at); const dayKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; if (dayKey !== lastDayKey) { const label = date.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' }); html += `
${label}
`; lastDayKey = dayKey; } html += renderAtivoCard(ann); }); grid.innerHTML = html; bindAtivoEvents(grid); // scroll-spy desativado: o dia é escolhido só clicando na barra de dias requestAnimationFrame(() => { grid.scrollTop = oldScroll; if (oldWindowScroll > 0) window.scrollTo({ top: oldWindowScroll, behavior: 'instant' }); }); // Destacar anúncio vindo do Financeiro (↗ Ver ou clique na enquete) const highlightAnn = window._mf_highlight_ann || sessionStorage.getItem('mf_highlight_ann'); if (highlightAnn) { window._mf_highlight_ann = null; sessionStorage.removeItem('mf_highlight_ann'); setTimeout(() => { const el = grid.querySelector(`[data-ann-id="${highlightAnn}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.transition = 'box-shadow .3s'; el.style.boxShadow = '0 0 0 3px #818cf8, 0 0 24px rgba(129,140,248,.5)'; setTimeout(() => { el.style.boxShadow = ''; }, 2500); } }, 300); } } if (newlyComplete.length > 0) { setTimeout(() => { newlyComplete.forEach(item => { const el = grid.querySelector(`[data-ann-id="${item.id}"]`); if (el) { el.classList.add('ann-card--celebrating'); el.addEventListener('animationend', () => el.classList.remove('ann-card--celebrating'), { once: true }); } }); launchConfetti(); playVictorySound(); // Mostrar popup com nome do anúncio e milestone showCelebrationPopup(newlyComplete); }, 200); } } catch (e) { grid.innerHTML = `

${esc(e.message)}

`; } } function renderGroupCard(group) { const totalQty = group.anns.reduce((s, a) => s + (a.qty_total || 0), 0); const totalParticipants = group.anns.reduce((s, a) => s + (a.all_intents?.length || 0), 0); const count = group.anns.length; // Verificar se algum anúncio atingiu a grade (destaque verde) const hasComplete = group.anns.some(a => { const target = a.target_qty ?? 0; return target > 0 && (a.qty_total || 0) >= target; }); const borderColor = hasComplete ? '#10b981' : '#6366f1'; const accentColor = hasComplete ? '#34d399' : '#818cf8'; return `
💬 Grupo WhatsApp
${esc(group.name)}
${count}
anúncio${count !== 1 ? 's' : ''}
${totalQty}
peças
${totalParticipants}
participante${totalParticipants !== 1 ? 's' : ''}
Gerenciar anúncios →
`; } function renderAtivoCard(ann) { const target = ann.target_qty ?? 0; const qtyTotal = ann.qty_total || 0; const currentRound = ann.round || 1; const closedRounds = ann.closed_rounds || 0; const priceDisplay = parseFloat(ann.sale_price).toFixed(2); const createdAt = new Date(ann.created_at); const dateStr = createdAt.toLocaleDateString('pt-BR') + ' às ' + createdAt.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const complete = target > 0 && qtyTotal >= target; // denominador: próximo múltiplo de target >= qtyTotal (ex: 48 pç com grade 24 → mostra 48/48) const displayTarget = target > 0 ? Math.max(target, Math.ceil(Math.max(qtyTotal, 1) / target) * target) : 0; const pct = target > 0 ? Math.round((qtyTotal / displayTarget) * 100) : 0; // Badge fixo do número da enquete — sempre visível, antes do título, // pra não ser escondido quando o nome do produto é longo. const seqBadge = ann.seq ? `#${ann.seq}` : ''; const historyBadge = closedRounds > 0 ? `✅ ${closedRounds} pacote${closedRounds > 1 ? 's' : ''} fechado${closedRounds > 1 ? 's' : ''}` : ''; const qtyLabel = target > 0 ? `${qtyTotal}/${displayTarget} peças` : `${qtyTotal} peças`; const targetBadge = target === 0 ? `♾️ Sem Limite` : `📋 Grade ${target}`; const progressHtml = `
${target === 0 ? `📦 Lote #${currentRound}` : complete ? (() => { const n = Math.floor(qtyTotal / target); return `🔒 ${String(n).padStart(2,'0')} pacote${n > 1 ? 's' : ''} fechado${n > 1 ? 's' : ''}!`; })() : `📦 pacote em aberto`}
${qtyLabel}
`; const colorsChips = Object.values(ann.byColor || {}).filter(c => c.qty_total > 0).map(c => `
${esc(c.color_name)} ${c.qty_total} peças
`).join(''); const allIntents = ann.all_intents || []; // ── PRECIFICAÇÃO (alta responsabilidade) ───────────────────────────── // Preço: exige R$, R, valor, preço ou casas decimais (ex: 30,00) const _priceFromName = (() => { const text = ann.product_name || ''; const m = text.match(/(?:[Rr]\$?|valor|preço|preco)\s*(\d+(?:[.,]\d{1,2})?)/i) || text.match(/\b(\d+[.,]\d{2})\b/); return m ? parseFloat(m[1].replace(',', '.')) : 0; })(); // sale_price do banco como verificação cruzada const _priceFromDb = ann.sale_price > 0 ? ann.sale_price : 0; // Prioridade: nome do anúncio (fonte de verdade visível), fallback banco const _unitPrice = _priceFromName > 0 ? _priceFromName : _priceFromDb; // Taxa fixa 13% — nunca usar fee_percent=0 de registros antigos const _FEE = 0.13; let clientsHtml = ''; if (allIntents.length > 0) { const rows = [...allIntents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)).map(i => { const initials = (i.customer_name || 'C').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); // SEMPRE recalcular — nunca confiar em total_amount armazenado (pode ter fee=0) const valorBruto = i.qty * _unitPrice; const valorTotal = _unitPrice > 0 ? valorBruto * (1 + _FEE) : 0; const detalhe = _unitPrice > 0 ? `${i.qty} peças × R$${_unitPrice.toFixed(2).replace('.', ',')} + 13% = ` : ''; const valorStr = _unitPrice > 0 ? `${detalhe}R$ ${valorTotal.toFixed(2).replace('.', ',')}` : ``; return `
${esc(initials)}
${esc(i.customer_name || 'Cliente')} ${i.customer_id ? `` : ''}
${esc(i.color_name || '')} · ${valorStr}
${i.qty} peças ${i.status === 'paid' ? `✅ ${i.end_to_end_id ? 'Pago Sicredi' : 'Pago Manual'}` : i.status === 'billed' ? `⏳ Cobrado` : `Reservado`}
`; }).join(''); clientsHtml = `
👥 Clientes — Pacote #${currentRound} (${allIntents.length})
${rows}
`; } else { clientsHtml = `
Aguardando reservas no grupo...
`; } const scoreBg = qtyTotal === 0 ? 'rgba(255,255,255,.04)' : complete ? 'linear-gradient(90deg,#f59e0b22,#f59e0b11)' : 'linear-gradient(90deg,#6366f122,#6366f111)'; const scoreColor = qtyTotal === 0 ? 'var(--text-muted)' : complete ? '#f59e0b' : '#818cf8'; const scoreVoters = ann.all_intents?.length || 0; return `
${qtyTotal} peças pedidas
🗳️ ${scoreVoters} participante${scoreVoters !== 1 ? 's' : ''} ${target > 0 ? `${pct}% da meta` : `sem meta definida`}
${seqBadge}🛍️ ${esc(ann.product_name)}${historyBadge}
${targetBadge}
${esc(ann.wa_group_name || ann.wa_group_id)} · R$ ${priceDisplay}/pç · ${dateStr}
${(ann.thumbnail || (ann.images && ann.images.length > 0)) ? `` : ''}
${(ann.images_count > 0) ? `
📷 ${ann.images_count} foto${ann.images_count !== 1 ? 's' : ''} ${(ann.images_count > 1) ? `
+${ann.images_count - 1}
` : ''}
` : '
'}
${progressHtml} ${colorsChips ? `
${colorsChips}
` : ''} ${clientsHtml}
`; } // Carrega thumbnails usando IntersectionObserver: só busca o visível (+20% margem). function lazyLoadThumbnailsInto(container, annIds, onThumb) { const unique = [...new Set(annIds)].filter(Boolean); if (!unique.length) return; for (const id of unique) { const sentinel = container.querySelector(`[data-thumb-ann-id="${id}"]`); if (!sentinel) continue; _observeThumb(sentinel, id, (thumbId, thumb, raw) => { container.querySelectorAll(`[data-thumb-ann-id="${thumbId}"]`).forEach(slot => { slot.innerHTML = ``; }); if (onThumb) onThumb(thumbId, thumb, raw); }); } } function bindAtivoEvents(grid) { // Lazy-load via IntersectionObserver: carrega só o visível (+20% à frente) grid.querySelectorAll('[data-thumb-id]').forEach(imgEl => { const id = imgEl.dataset.thumbId; if (!id) return; _observeThumb(imgEl, id, (thumbId, thumb, raw) => { imgEl.src = thumb; imgEl.dataset.fullSrc = raw; imgEl.style.display = ''; const phEl = grid.querySelector(`[data-thumb-ph="${thumbId}"]`); if (phEl) phEl.style.display = 'none'; }); }); // Auto-load das fotos quando a strip entra no viewport (sem precisar clicar). // Click numa thumb continua abrindo lightbox (handler global no body). const loadStripPhotos = async (strip) => { if (strip.dataset.photosLoaded !== 'false') return; strip.dataset.photosLoaded = 'loading'; try { const data = await get(`/announcements/${strip.dataset.photosAnn}/images`); const imgs = data.images || []; if (imgs.length) { strip.innerHTML = imgs.map(src => ``).join(''); } else { strip.innerHTML = 'Sem fotos'; } strip.dataset.photosLoaded = 'true'; } catch { strip.dataset.photosLoaded = 'false'; } }; const stripObs = new IntersectionObserver((entries) => { for (const ent of entries) { if (ent.isIntersecting) { stripObs.unobserve(ent.target); loadStripPhotos(ent.target); } } }, { rootMargin: '200px' }); grid.querySelectorAll('[data-photos-ann]').forEach(strip => { stripObs.observe(strip); }); grid.querySelectorAll('[data-pay]').forEach(btn => btn.addEventListener('click', () => markPix(btn.dataset.pay, btn))); grid.querySelectorAll('[data-purchase]').forEach(btn => btn.addEventListener('click', () => showPurchaseList(btn.dataset.purchase, btn.dataset.round))); grid.querySelectorAll('[data-edit-ann]').forEach(btn => btn.addEventListener('click', () => openEditAnnModal(btn.dataset.editAnn))); grid.querySelectorAll('[data-delete-ann]').forEach(btn => btn.addEventListener('click', () => deleteAnn(btn.dataset.deleteAnn, btn.dataset.deleteName))); grid.querySelectorAll('[data-close-package]').forEach(btn => btn.addEventListener('click', () => closePackage(btn.dataset.closePackage))); grid.querySelectorAll('[data-lot-labels]').forEach(btn => btn.addEventListener('click', () => { const annId = btn.dataset.lotLabels; const round = btn.dataset.lotRound || 1; window.open(`/api/orders/lot:${annId}::${round}/lot-labels`, '_blank'); })); grid.querySelectorAll('[data-lot-labels-pdf]').forEach(btn => btn.addEventListener('click', () => { const annId = btn.dataset.lotLabelsPdf; const round = btn.dataset.lotRound || 1; window.open(`/api/orders/lot:${annId}::${round}/lot-labels?pdf=1`, '_blank'); })); grid.querySelectorAll('.btn-edit-client').forEach(btn => btn.addEventListener('click', (e) => { e.stopPropagation(); openClientEditModal(btn.dataset.customerId, btn.dataset.customerName); })); grid.querySelectorAll('[data-upload-photos]').forEach(input => { input.addEventListener('change', async () => { const annId = input.dataset.uploadPhotos; const files = Array.from(input.files); if (!files.length) return; const label = input.closest('label'); label.textContent = '⏳'; try { const b64s = await Promise.all(files.map(f => new Promise((res, rej) => { const r = new FileReader(); r.onload = e => res(e.target.result); r.onerror = rej; r.readAsDataURL(f); }))); await patch(`/announcements/${annId}`, { images: b64s, send_to_wa: false }); toast(`✅ ${b64s.length} foto${b64s.length > 1 ? 's' : ''} salva${b64s.length > 1 ? 's' : ''}!`); label.textContent = `📷 ${b64s.length} foto${b64s.length > 1 ? 's' : ''}`; } catch (e) { toast('Erro ao salvar fotos: ' + e.message, 'error'); label.textContent = '📷 Fotos'; } }); }); } // ── ABA: ANÚNCIOS FECHADOS ──────────────────────────────────────────────── // ── ABA: ANÚNCIOS FECHADOS ──────────────────────────────────────────────── async function loadFechados(isBackground = false) { const wrap = document.getElementById('annGridFechados'); const head = document.getElementById('fechadosHeader'); // Se já temos dados e não é um refresh de fundo, renderiza do cache imediatamente if (_lastFechadosData && !isBackground) { _renderFechadosUI(_lastFechadosData); } else if (!wrap.innerHTML || wrap.querySelector('.mf-spinner')) { // Só mostra spinner se não tivermos nada carregado wrap.className = ''; wrap.innerHTML = `
Carregando lotes fechados...
`; } try { // Carrega todos os lotes de uma vez (resposta já otimizada com slim-ize) const data = await get('/announcements/fechados'); const lots = data.lots || []; // Gerar assinatura — inclui contagem de post_close pra invalidar quando // novos cliques pós-fechamento entram (mudam a ordem da lista) const newSig = lots.map(l => { const pc = (l.intents || []).filter(i => i.post_close).length; return `${l.announcement_id}:${l.round}:${l.intents?.length}:${l.announcement_status}:pc${pc}`; }).sort().join('|'); if (newSig === _lastFechadosSignature && _lastFechadosData) { // Se nada mudou e já renderizamos (via cache ou fetch anterior), apenas garante que o cabeçalho está ok if (head.hidden) _renderFechadosUI(data); return; } _lastFechadosSignature = newSig; _lastFechadosData = data; _renderFechadosUI(data); } catch (err) { if (!isBackground) { wrap.innerHTML = `

Erro: ${err.message}

`; } } } function _renderFechadosUI(data) { const wrap = document.getElementById('annGridFechados'); const head = document.getElementById('fechadosHeader'); let lots = data.lots || []; window._fechadosSuppliers = data.suppliers || []; // Filtro de busca if (_annSearchQuery) { const q = _annSearchQuery.toLowerCase().trim(); lots = lots.filter(l => (l.product_name || '').toLowerCase().includes(q) || (l.seq && `#${l.seq}`.toLowerCase().includes(q)) || (l.seq && String(l.seq).includes(q)) || (l.intents || []).some(i => (i.customer_name || '').toLowerCase().includes(q)) ); } lots.forEach(l => { if (l.product_name) l.product_name = stripEmojis(l.product_name); if (l.intents) l.intents.forEach(i => { if (i.customer_name) i.customer_name = stripEmojis(i.customer_name); }); }); // Agrupar por announcement_id const groups = {}; for (const lot of lots) { if (!groups[lot.announcement_id]) { groups[lot.announcement_id] = { announcement_id: lot.announcement_id, product_name: lot.product_name, seq: lot.seq, wa_group_name: lot.wa_group_name, announcement_status: lot.announcement_status, is_manual: lot.is_manual || false, lots: [], total_amount: 0, paid_count: 0, billed_count: 0, total_count: 0, images: lot.images || [], created_at: lot.created_at || null, }; } groups[lot.announcement_id].lots.push(lot); groups[lot.announcement_id].total_amount = +(groups[lot.announcement_id].total_amount + Number(lot.total_amount || 0)).toFixed(2); groups[lot.announcement_id].paid_count += (lot.paid_count || 0); groups[lot.announcement_id].billed_count += (lot.billed_count || 0); groups[lot.announcement_id].total_count += (lot.intent_count || 0); } for (const g of Object.values(groups)) g.lots.sort((a, b) => a.round - b.round); const sortedGroups = Object.values(groups).sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); // Mapear dias do calendário (CHAVES y-m-d dentro da janela) const _fwin = _fechWindowRange(); const _fwinStart = _fwin.start.getTime(), _fwinEnd = _fwin.end.getTime(); _fechDaysSet.clear(); sortedGroups.forEach(g => { if (g.created_at) { const t = new Date(g.created_at).getTime(); if (t >= _fwinStart && t <= _fwinEnd) _fechDaysSet.add(_dkeyOf(g.created_at)); } }); // Veio do Financeiro com ?ann= → abre no dia daquele anúncio (fica verde) if (window._mf_highlight_ann) { const alvo = sortedGroups.find(g => g.announcement_id === window._mf_highlight_ann && g.created_at); if (alvo) { const t = new Date(alvo.created_at).getTime(); if (t >= _fwinStart && t <= _fwinEnd) { _fechCalDay = _dkeyOf(alvo.created_at); _fechFirstLoad = false; } } } // 1º load: seleciona o dia de hoje (se tiver lotes) ou o mais recente — fica verde if (_fechFirstLoad) { _fechFirstLoad = false; const hjKey = _dkeyOf(new Date()); if (_fechDaysSet.has(hjKey)) { _fechCalDay = hjKey; } else { const keys = [..._fechDaysSet]; _fechCalDay = keys.length ? keys.sort((a, b) => _dkeyToDate(a) - _dkeyToDate(b)).pop() : null; } } // Quando search está ativa, mostra tudo. Sem dia selecionado → janela inteira. const visibleGroups = _annSearchQuery ? sortedGroups : (_fechCalDay === null ? sortedGroups.filter(g => { if (!g.created_at) return false; const t = new Date(g.created_at).getTime(); return t >= _fwinStart && t <= _fwinEnd; }) : sortedGroups.filter(g => g.created_at && _dkeyOf(g.created_at) === _fechCalDay)); head.hidden = false; head.innerHTML = `
`; { const _c = document.getElementById('tabCountFechados'); if (_c) _c.textContent = visibleGroups.length || ''; } renderFechadosCalendar(); if (!visibleGroups.length) { const _fSel = _fechCalDay ? _dkeyToDate(_fechCalDay) : null; wrap.innerHTML = `
📦

${_fSel ? `Sem lotes em ${String(_fSel.getDate()).padStart(2,'0')}/${String(_fSel.getMonth()+1).padStart(2,'0')}.` : 'Nenhum lote aguardando cobrança.'}

`; return; } window._fechadosGroups = groups; wrap.className = 'kanban-board'; let html = ''; let lastDayKey = null; for (const g of visibleGroups) { if (g.created_at) { const d = new Date(g.created_at); const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; if (dayKey !== lastDayKey) { const label = d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' }); html += `
${label}
`; lastDayKey = dayKey; } } const seqTag = g.seq ? `#${String(g.seq).padStart(2, '0')}` : ''; const allPaid = g.paid_count === g.total_count && g.total_count > 0; const totalPcs = g.lots.reduce((s, l) => s + (l.intents || []).filter(i => i.status !== 'cancelled').reduce((s2, i) => s2 + (Number(i.qty) || 0), 0), 0); const supplierName = g.lots[0]?.supplier_name || ''; // Cobrados = intents que já tiveram cobrança enviada (status='billed' ou 'paid') const billedCount = g.lots.reduce((s, l) => s + (l.intents || []).filter(i => i.status === 'billed' || i.status === 'paid').length, 0); const billedBadge = billedCount > 0 ? `📨 ${billedCount}/${g.total_count} cobrado${billedCount !== 1 ? 's' : ''}` : ''; const paidBadge = allPaid ? `✅ Tudo pago` : `⏳ ${g.paid_count}/${g.total_count} pago`; // Clientes POS (clicaram após o fechamento) — badge com a contagem let posCount = 0; const posClients = []; g.lots.forEach(l => (l.intents || []).forEach(i => { if (i.post_close && i.status !== 'cancelled') { posCount++; posClients.push(i.customer_name || 'Cliente'); } })); const posBadge = posCount > 0 ? `⏰ ${posCount} POS` : ''; html += `
${allPaid ? '' : ''}
${(g.images[0] || _thumbCache.get(g.announcement_id)) ? `` : '📦'}
${esc(g.product_name)}
${g.is_manual ? `🛍️ Manual` : ''}
${g.lots.length} lote${g.lots.length !== 1 ? 's' : ''} · ${esc(g.wa_group_name || 'Sem grupo')}
${supplierName ? `
${esc(supplierName)}
` : ''} ${posBadge}
`; } wrap.innerHTML = html; // Lazy-load thumbnails em background (imagens excluídas do payload principal para performance) lazyLoadThumbnailsInto(wrap, Object.keys(groups), (annId, thumb, raw) => { if (window._fechadosGroups?.[annId]) { window._fechadosGroups[annId].images = [thumb]; window._fechadosGroups[annId].imageRaw = raw || thumb; } }); wrap.querySelectorAll('.fechado-ann-card').forEach(card => card.addEventListener('click', () => openFechadoDetail(card.dataset.annId))); // Destacar anúncio vindo do Financeiro (clique na enquete → nova aba) const _hlAnnF = window._mf_highlight_ann || sessionStorage.getItem('mf_highlight_ann'); if (_hlAnnF) { window._mf_highlight_ann = null; sessionStorage.removeItem('mf_highlight_ann'); setTimeout(() => { const el = wrap.querySelector(`[data-ann-id="${_hlAnnF}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.transition = 'box-shadow .3s'; el.style.boxShadow = '0 0 0 3px #818cf8, 0 0 24px rgba(129,140,248,.5)'; setTimeout(() => { el.style.boxShadow = ''; }, 2500); } }, 300); } wrap.querySelectorAll('[data-reopen-ann]').forEach(btn => btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.reopenAnn, name = btn.dataset.reopenName; if (!confirm(`Reabrir "${name}"?`)) return; btn.disabled = true; btn.textContent = '⏳'; try { await post(`/announcements/${id}/reopen`, {}); toast('Anúncio reaberto!', 'success'); loadFechados(); } catch (err) { toast(err.message, 'error'); btn.disabled = false; btn.textContent = '↩'; } })); wrap.querySelectorAll('[data-delete-ann]').forEach(btn => btn.addEventListener('click', (e) => { e.stopPropagation(); deleteAnn(btn.dataset.deleteAnn, btn.dataset.deleteName); })); } // ── Modal de detalhe: Anúncio Fechado ──────────────────────────────────── async function openFechadoDetail(annId) { const g = (window._fechadosGroups || {})[annId]; if (!g) return; const seqTag = g.seq ? ` #${String(g.seq).padStart(2, '0')}` : ''; const thumb = g.images[0] || ''; const thumbFull = g.imageRaw || thumb; const modal = document.getElementById('modalFechadoDetail'); const titleEl = document.getElementById('fechadoDetailTitle'); titleEl.innerHTML = ` ${thumb ? `` : '📦 '} ${esc(g.product_name)}${esc(seqTag)}`; if (thumb) { const imgEl = titleEl.querySelector('img'); if (imgEl) imgEl.addEventListener('click', () => openLightbox(thumbFull)); } // Botão adicionar/trocar foto do anúncio const btnPhoto = document.getElementById('btnFechadoPhoto'); const photoInput = document.getElementById('fechadoPhotoInput'); btnPhoto.title = thumb ? 'Trocar foto' : 'Adicionar foto'; btnPhoto.onclick = () => photoInput.click(); photoInput.onchange = () => { const f = photoInput.files?.[0]; if (!f) return; if (f.size > 10 * 1024 * 1024) { toast('Imagem muito grande (máx 10MB)', 'error'); photoInput.value = ''; return; } const reader = new FileReader(); reader.onload = async () => { const result = String(reader.result || ''); const comma = result.indexOf(','); const data = comma >= 0 ? result.slice(comma + 1) : result; const mimeType = f.type || 'image/jpeg'; btnPhoto.disabled = true; btnPhoto.style.opacity = '0.4'; try { // anuncios.html usa helpers do /js/app.js — não tem API/TOKEN globais const out = await post(`/announcements/${annId}/photo`, { data, mimeType }); const newUrl = out.url; // Atualiza cache, thumb da capa e título do modal g.images = [newUrl, ...(g.images || []).filter(u => u !== newUrl)]; _thumbCache.set(annId, newUrl); if (_lastFechados) _lastFechados.forEach(l => { if (l.announcement_id === annId) l.images = g.images; }); const _titleEl3 = document.getElementById('fechadoDetailTitle'); _titleEl3.innerHTML = ` ${esc(g.product_name)}${esc(seqTag)}`; const _i3 = _titleEl3.querySelector('img'); if (_i3) _i3.addEventListener('click', () => openLightbox(newUrl)); btnPhoto.title = 'Trocar foto'; // Atualiza thumbs nos cards da grade (se ainda estiver renderizada) document.querySelectorAll(`[data-thumb-ann-id="${annId}"]`).forEach(box => { box.innerHTML = ``; }); toast('✅ Foto atualizada!', 'success'); } catch (e) { toast('Erro ao enviar foto: ' + e.message, 'error'); } finally { btnPhoto.disabled = false; btnPhoto.style.opacity = '0.7'; photoInput.value = ''; } }; reader.readAsDataURL(f); }; // Botão editar nome da peça const btnEdit = document.getElementById('btnEditProductName'); btnEdit.onclick = async () => { const currentName = g.product_name || ''; const newName = prompt('Nome da peça:', currentName); if (!newName || newName.trim() === currentName.trim()) return; try { await patch(`/announcements/${annId}`, { product_name: newName.trim() }); g.product_name = newName.trim(); const _titleEl2 = document.getElementById('fechadoDetailTitle'); _titleEl2.innerHTML = ` ${thumb ? `` : '📦 '} ${esc(g.product_name)}${esc(seqTag)}`; if (thumb) { const _i2 = _titleEl2.querySelector('img'); if (_i2) _i2.addEventListener('click', () => openLightbox(thumbFull)); } // Atualiza no cache local if (_lastFechados) { _lastFechados.forEach(l => { if (l.announcement_id === annId) l.product_name = g.product_name; }); } toast('✅ Nome atualizado!', 'success'); } catch (e) { toast('Erro ao salvar: ' + e.message, 'error'); } }; const body = document.getElementById('fechadoDetailBody'); // Abrir modal imediatamente com spinner — intents são carregados sob demanda body.innerHTML = `

Carregando...

`; modal.hidden = false; // O endpoint /fechados omite intents por performance; buscar sempre frescos try { window.__intentTransfers = { from: {}, to: {} }; await Promise.all(g.lots.map(async lot => { const data = await get(`/announcements/lots/${lot.announcement_id}::${lot.round}/intents`); lot.intents = (data.intents || []).filter(i => i.status !== 'cancelled'); })); // Disponibiliza lots em memória pra outros modais (ex: candidatos POS no Repasse) window._currentFechadoLots = g.lots; // Carrega relações de repasse de todos os intents deste anúncio try { const allIds = g.lots.flatMap(l => (l.intents || []).map(i => i.id)); if (allIds.length) { const tr = await post('/announcement-intents/transfers', { intent_ids: allIds }); if (tr) { Object.assign(window.__intentTransfers.from, tr.from || {}); Object.assign(window.__intentTransfers.to, tr.to || {}); } } } catch (_) {} } catch (e) { body.innerHTML = `

Erro ao carregar: ${esc(e.message)}

`; return; } // Barra de atalhos rápidos no topo do modal const quickBar = document.getElementById('fechadoQuickBar'); if (quickBar) { quickBar.innerHTML = g.lots.map(lot => `
Lote #${lot.round}
`).join(''); quickBar.style.display = 'flex'; // Conectar listeners attachFechadoLotListeners(quickBar, annId); } const lotsHtml = g.lots.map(lot => renderLotCard(lot)).join(''); // Estrutura que o qty editor espera: prod-row → prod-lots → lot-card body.innerHTML = `
${lotsHtml}
`; attachFechadoLotListeners(body, annId); } function attachFechadoLotListeners(container, annId) { container.querySelectorAll('[data-delete-lot]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); deleteLot(btn.dataset.deleteLot, btn.dataset.round); }); }); container.querySelectorAll('[data-cobrar-lot]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); openBillingModal(btn.dataset.cobrarLot); }); }); container.querySelectorAll('[data-purchase]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); showPurchaseList(btn.dataset.purchase, btn.dataset.round); }); }); container.querySelectorAll('[data-cobrar-grade]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); openBillingModal(btn.dataset.cobrarGrade, btn.dataset.gradeIds.split(',').filter(Boolean)); }); }); container.querySelectorAll('[data-descartar-grade]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); discardSubLot(btn.dataset.gradeIds.split(',').filter(Boolean), btn); }); }); container.querySelectorAll('[data-pay]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); // Anti-duplo-clique enquanto a request está em vôo if (btn.dataset.pending === '1') return; if (btn.tagName === 'INPUT' && btn.type === 'checkbox') { if (btn.checked) markPixOptimistic(btn.dataset.pay, btn); else unmarkPixOptimistic(btn.dataset.pay, btn); } else { // Botão clássico ("✓ PIX") — só marca markPix(btn.dataset.pay, btn); } }); }); container.querySelectorAll('.btn-edit-client').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); openClientEditModal(btn.dataset.customerId, btn.dataset.customerName); }); }); container.querySelectorAll('.btn-delete-intent').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); deleteIntent(btn.dataset.intentId, btn.dataset.customer, annId); }); }); container.querySelectorAll('.btn-transfer-intent').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); window._currentFechadoAnnId = annId; openTransferModal(btn.dataset.intentId, btn.dataset.customer, btn.dataset.qty, btn.dataset.total); }); }); container.querySelectorAll('[data-recount-poll]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const targetAnnId = btn.dataset.recountPoll; const targetRound = btn.dataset.recountRound || 1; if (!confirm('Deseja buscar votos no histórico do WhatsApp deste grupo para este lote?')) return; btn.disabled = true; const old = btn.innerHTML; btn.innerHTML = '⏳...'; try { const res = await post(`/announcements/${targetAnnId}/recount-poll`, { round: targetRound }); alert(`Sincronização concluída!\n${res.count || 0} votos processados.`); // Recarregar lista do modal se estiver aberto if (!document.getElementById('modalFechadoDetail').hidden) { showPurchaseList(targetAnnId); } loadFechados(); } catch (err) { alert('Erro: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = old; } }); }); container.querySelectorAll('[data-arquivar-lot]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); arquivarLot(btn.dataset.arquivarLot, Number(btn.dataset.round), btn); }); }); container.querySelectorAll('[data-toggle-grade]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.toggleGrade; const nextGrade = Number(btn.dataset.nextGrade); btn.disabled = true; btn.textContent = '⏳'; try { await patch(`/announcements/${id}`, { target_qty: nextGrade }); toast(nextGrade === 0 ? '∞ Modo Sem Limite ativado' : '🎯 Grade 24 ativada', 'success'); document.getElementById('modalFechadoDetail').hidden = true; loadFechados(); } catch (err) { toast('Erro: ' + err.message, 'error'); btn.disabled = false; } }); }); container.querySelectorAll('[data-novo-round]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); btn.disabled = true; btn.textContent = '⏳'; try { await patch(`/announcements/${btn.dataset.novoRound}/toggle`, {}); toast('✅ Anúncio reativado! Novo round iniciado.'); document.getElementById('modalFechadoDetail').hidden = true; loadFechados(); } catch (err) { toast('Erro: ' + err.message, 'error'); btn.disabled = false; btn.textContent = '▶ Novo Round'; } }); }); container.querySelectorAll('.delivery-date-input').forEach(input => { input.closest('label')?.addEventListener('click', (e) => { if (e.target === input) return; e.preventDefault(); try { input.showPicker(); } catch { input.focus(); } }); input.addEventListener('change', async (e) => { e.stopPropagation(); const id = input.dataset.annId; const round = Number(input.dataset.round); const date = input.value || null; try { await patch(`/announcements/${id}/delivery-date`, { round, date }); toast(date ? `📅 Previsão salva: ${date.split('-').reverse().join('/')}` : 'Data removida', 'success'); document.getElementById('modalFechadoDetail').hidden = true; loadFechados(); } catch (err) { toast('Erro ao salvar data: ' + err.message, 'error'); } }); }); container.querySelectorAll('.delivery-date-clear').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); try { await patch(`/announcements/${btn.dataset.annId}/delivery-date`, { round: Number(btn.dataset.round), date: null }); toast('Data removida', 'success'); document.getElementById('modalFechadoDetail').hidden = true; loadFechados(); } catch (err) { toast('Erro: ' + err.message, 'error'); } }); }); container.querySelectorAll('.lot-supplier-select').forEach(sel => { sel.addEventListener('change', async (e) => { e.stopPropagation(); const supplierId = sel.value || null; try { await patch(`/announcements/${sel.dataset.annId}`, { supplier_id: supplierId }); toast(supplierId ? '🏭 Fornecedor vinculado' : 'Fornecedor removido', 'success'); } catch (err) { toast('Erro: ' + err.message, 'error'); } }); }); container.querySelectorAll('[data-lot-labels-foot]').forEach(btn => { btn.addEventListener('click', () => { window.open(`/api/orders/lot:${btn.dataset.lotLabelsFoot}::${btn.dataset.lotRound || 1}/lot-labels`, '_blank'); }); }); container.querySelectorAll('[data-lot-labels-pdf-foot]').forEach(btn => { btn.addEventListener('click', () => { window.open(`/api/orders/lot:${btn.dataset.lotLabelsPdfFoot}::${btn.dataset.lotRound || 1}/lot-labels?pdf=1`, '_blank'); }); }); container.querySelectorAll('[data-manual-sale]').forEach(btn => { btn.addEventListener('click', () => { openManualSaleModal({ annId: btn.dataset.manualSale, round: Number(btn.dataset.lotRound || 1), salePrice: parseFloat(btn.dataset.salePrice || 0), feePercent: parseFloat(btn.dataset.feePercent ?? 13), productName: btn.dataset.productName, }); }); }); // Editor inline de quantidade container.querySelectorAll('.btn-edit-qty').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); container.querySelectorAll('.qty-inline-editor').forEach(el => el.replaceWith(el._origQtySpan || el)); const intentId = btn.dataset.intentId; const currentQty = Number(btn.dataset.qty); const unitPrice = Number(btn.dataset.unitPrice) || 0; const fee = Number(btn.dataset.fee) ?? 13; const row = btn.closest('.client-row'); const qtyWrap = btn.closest('.client-qty-wrap'); const qtySpan = qtyWrap.querySelector('.client-qty'); const detailEl = row.querySelector('.client-detail'); const unitWithFee = unitPrice > 0 ? unitPrice * (1 + fee / 100) : (currentQty > 0 ? Number(detailEl?.dataset?.total || 0) / currentQty : 0); const editor = document.createElement('div'); editor.className = 'qty-inline-editor'; editor._origQtySpan = qtySpan; editor.innerHTML = `
`; qtySpan.replaceWith(editor); btn.style.opacity = '0'; btn.style.pointerEvents = 'none'; const input = editor.querySelector('.qty-pop-input'); input.focus(); input.select(); const updateTotal = () => { const q = parseInt(input.value) || 0; if (unitWithFee > 0 && detailEl) { const newTotal = (q * unitWithFee).toFixed(2); const parts = detailEl.textContent.split('·'); detailEl.textContent = `${parts[0]?.trim() || ''} · R$ ${newTotal}`; detailEl.dataset.total = newTotal; const lotCard = row.closest('.lot-card'); if (lotCard) { const lotTotalEl = lotCard.querySelector('[data-lot-total]'); if (lotTotalEl) { lotTotalEl.textContent = `R$ ${Array.from(lotCard.querySelectorAll('.client-detail[data-total]')).reduce((s, el) => s + parseFloat(el.dataset.total || 0), 0).toFixed(2)}`; } } } }; editor.querySelector('.qty-pop-minus').addEventListener('click', ev => { ev.stopPropagation(); const v = parseInt(input.value)||1; if(v>1){input.value=v-1;updateTotal();} }); editor.querySelector('.qty-pop-plus').addEventListener('click', ev => { ev.stopPropagation(); input.value=(parseInt(input.value)||0)+1; updateTotal(); }); input.addEventListener('input', updateTotal); const cancel = () => { editor.replaceWith(qtySpan); btn.style.opacity = ''; btn.style.pointerEvents = ''; if (detailEl) { const parts = detailEl.textContent.split('·'); detailEl.textContent = `${parts[0]?.trim() || ''} · R$ ${(currentQty * unitWithFee).toFixed(2)}`; } }; editor.querySelector('.qty-pop-cancel').addEventListener('click', ev => { ev.stopPropagation(); cancel(); }); const save = async (ev) => { if (ev) ev.stopPropagation(); const newQty = parseInt(input.value); if (!newQty || newQty < 1) { input.focus(); return; } const confirmBtn = editor.querySelector('.qty-pop-confirm'); confirmBtn.disabled = true; confirmBtn.textContent = '…'; try { const result = await patch(`/announcements/intents/${intentId}/qty`, { qty: newQty }); const newQtySpan = document.createElement('span'); newQtySpan.className = 'client-qty'; newQtySpan.textContent = `${newQty} peças`; editor.replaceWith(newQtySpan); btn.dataset.qty = newQty; btn.style.opacity = ''; btn.style.pointerEvents = ''; qtyWrap.appendChild(btn); const lotCard = row.closest('.lot-card'); if (lotCard) { const lotTotalEl = lotCard.querySelector('[data-lot-total]'); if (lotTotalEl) lotTotalEl.textContent = `R$ ${Array.from(lotCard.querySelectorAll('.client-detail[data-total]')).reduce((s, el) => s + parseFloat(el.dataset.total || 0), 0).toFixed(2)}`; } toast('Quantidade atualizada', 'success'); } catch (err) { toast('Erro: ' + err.message, 'error'); cancel(); } }; editor.querySelector('.qty-pop-confirm').addEventListener('click', save); input.addEventListener('keydown', ev => { ev.stopPropagation(); if(ev.key==='Enter')save(ev); if(ev.key==='Escape')cancel(); }); }); }); container.querySelectorAll('.btn-obs-intent').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); toggleObsPanel(btn.dataset.intentId); }); }); } // ── Observações por Intenção ────────────────────────────────────────────── function toggleObsPanel(intentId) { const panel = document.getElementById(`obs-panel-${intentId}`); if (!panel) return; const open = panel.style.display !== 'none'; panel.style.display = open ? 'none' : 'block'; if (!open) { const input = document.getElementById(`obs-input-${intentId}`); if (input) { setTimeout(() => input.focus(), 50); if (!input.dataset.obsBlurBound) { input.dataset.obsBlurBound = '1'; input.addEventListener('blur', (e) => { if (!input.value.trim()) return; // Se o foco foi para o botão Add, ele já vai salvar — não duplicar if (e.relatedTarget && e.relatedTarget.textContent?.trim().includes('Add')) return; window.addIntentObs(intentId); }); } } } } window.addIntentObs = async function(intentId) { const input = document.getElementById(`obs-input-${intentId}`); if (!input) return; const text = input.value.trim(); if (!text) { input.focus(); return; } const btn = input.nextElementSibling; if (btn) { btn.disabled = true; btn.textContent = '...'; } try { const list = document.getElementById(`obs-list-${intentId}`); const current = Array.from(list?.querySelectorAll('li') || []).map(li => li.querySelector('span')?.textContent?.replace(/^• /, '') || '').filter(Boolean); const newObs = [...current, text]; await put(`/announcements/intents/${intentId}/obs`, { obs: newObs }); input.value = ''; _refreshObsPanel(intentId, newObs); toast('Observação salva', 'success'); } catch (err) { toast('Erro ao salvar obs: ' + err.message, 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = '+ Add'; } } }; window.deleteIntentObs = async function(intentId, idx) { try { const list = document.getElementById(`obs-list-${intentId}`); const current = Array.from(list?.querySelectorAll('li') || []).map(li => li.querySelector('span')?.textContent?.replace(/^• /, '') || '').filter(Boolean); const newObs = current.filter((_, i) => i !== idx); await put(`/announcements/intents/${intentId}/obs`, { obs: newObs }); _refreshObsPanel(intentId, newObs); toast('Observação removida'); } catch (err) { toast('Erro ao remover obs: ' + err.message, 'error'); } }; function _refreshObsPanel(intentId, obs) { const list = document.getElementById(`obs-list-${intentId}`); if (list) { list.innerHTML = obs.map((o, idx) => `
  • • ${esc(o)}
  • `).join(''); } const obsBtn = document.querySelector(`.btn-obs-intent[data-intent-id="${intentId}"]`); if (obsBtn) { const hasObs = obs.length > 0; obsBtn.style.background = hasObs ? '#f59e0b' : 'none'; obsBtn.style.border = hasObs ? '1px solid #f59e0b' : 'none'; obsBtn.style.padding = hasObs ? '2px 8px' : '2px 4px'; obsBtn.style.color = hasObs ? '#fff' : 'var(--text)'; obsBtn.innerHTML = `Obs${hasObs ? ` ${obs.length}` : ''}`; } } // ── Venda Manual ────────────────────────────────────────────────────────── let _manualSaleCtx = null; let _manualSaleSearchTimer = null; function openManualSaleModal({ annId, round, salePrice, feePercent, productName }) { _manualSaleCtx = { annId, round, salePrice, feePercent }; document.getElementById('manualSaleProductName').textContent = productName || ''; document.getElementById('manualSaleClientSearch').value = ''; document.getElementById('manualSaleClientId').value = ''; document.getElementById('manualSaleClientName').value = ''; document.getElementById('manualSaleClientResults').innerHTML = ''; document.getElementById('manualSaleClientResults').style.display = 'none'; document.getElementById('manualSaleQty').value = '1'; document.getElementById('manualSalePreview').style.display = 'none'; openModal('modalManualSale'); document.getElementById('manualSaleClientSearch').focus(); updateManualSalePreview(); } function updateManualSalePreview() { if (!_manualSaleCtx) return; const qty = parseInt(document.getElementById('manualSaleQty').value) || 0; const { salePrice, feePercent } = _manualSaleCtx; const preview = document.getElementById('manualSalePreview'); const totalEl = document.getElementById('manualSaleTotal'); if (qty > 0 && salePrice > 0) { const total = qty * salePrice * (1 + feePercent / 100); const fee = Math.round(feePercent); totalEl.textContent = `${qty} × R$ ${salePrice.toFixed(2).replace('.', ',')} + ${fee}% = R$ ${total.toFixed(2).replace('.', ',')}`; preview.style.display = 'block'; } else { preview.style.display = 'none'; } } document.getElementById('manualSaleQty').addEventListener('input', updateManualSalePreview); document.getElementById('manualSaleClientSearch').addEventListener('input', async (e) => { clearTimeout(_manualSaleSearchTimer); const q = e.target.value.trim(); const list = document.getElementById('manualSaleClientResults'); if (q.length < 2) { list.style.display = 'none'; return; } _manualSaleSearchTimer = setTimeout(async () => { try { const data = await get('/customers?q=' + encodeURIComponent(q)); const results = (data.customers || []).slice(0, 10); list.innerHTML = results.map(c => `
  • ${esc(c.name)}
  • ` ).join('') || `
  • Nenhum cliente encontrado
  • `; list.style.display = 'block'; list.querySelectorAll('li[data-cid]').forEach(li => { li.addEventListener('mousedown', () => { document.getElementById('manualSaleClientId').value = li.dataset.cid; document.getElementById('manualSaleClientName').value = li.dataset.cname; document.getElementById('manualSaleClientSearch').value = li.dataset.cname; list.style.display = 'none'; }); }); } catch { list.style.display = 'none'; } }, 300); }); document.getElementById('manualSaleClientSearch').addEventListener('blur', () => { setTimeout(() => { const list = document.getElementById('manualSaleClientResults'); list.style.display = 'none'; // Se digitou manualmente sem selecionar, usar o texto como nome const typed = document.getElementById('manualSaleClientSearch').value.trim(); if (typed && !document.getElementById('manualSaleClientName').value) { document.getElementById('manualSaleClientName').value = typed; } }, 200); }); document.getElementById('btnSaveManualSale').addEventListener('click', async () => { if (!_manualSaleCtx) return; const clientName = document.getElementById('manualSaleClientName').value.trim() || document.getElementById('manualSaleClientSearch').value.trim(); const clientId = document.getElementById('manualSaleClientId').value || null; const qty = parseInt(document.getElementById('manualSaleQty').value) || 0; if (!clientName) { toast('Informe o cliente', 'error'); return; } if (qty < 1) { toast('Informe a quantidade', 'error'); return; } const btn = document.getElementById('btnSaveManualSale'); btn.disabled = true; btn.textContent = '⏳'; try { await post(`/announcements/${_manualSaleCtx.annId}/intents/manual`, { customer_id: clientId, customer_name: clientName, qty, round: _manualSaleCtx.round, }); toast('✅ Venda manual adicionada!', 'success'); closeModal('modalManualSale'); loadFechados(); } catch (err) { toast('Erro: ' + err.message, 'error'); } finally { btn.disabled = false; btn.textContent = '💾 Adicionar Venda'; } }); // ── Anúncio Manual ──────────────────────────────────────── // Máscara currency: digita centavos, mostra formatado (ex: 1999 → 19,99) (function() { const el = document.getElementById('manualAnnPrice'); el.addEventListener('input', function() { let digits = this.value.replace(/\D/g, ''); if (!digits) { this.value = ''; this.dataset.cents = '0'; return; } const cents = parseInt(digits, 10); this.dataset.cents = cents; const reais = (cents / 100).toFixed(2).replace('.', ','); this.value = reais; }); })(); let _manualAnnClients = []; let _manualAnnPhotoB64 = null; let _manualAnnSelectedClient = null; window.openManualAnnModal = function() { _manualAnnClients = []; _manualAnnPhotoB64 = null; _manualAnnSelectedClient = null; document.getElementById('manualAnnName').value = ''; document.getElementById('manualAnnPrice').value = ''; document.getElementById('manualAnnFee').value = '13'; document.getElementById('manualAnnPhoto').value = ''; document.getElementById('manualAnnPhotoPreview').style.display = 'none'; document.getElementById('manualAnnClientSearch').value = ''; document.getElementById('manualAnnClientResults').style.display = 'none'; document.getElementById('manualAnnClientQty').value = '1'; document.getElementById('manualAnnClientItems').innerHTML = ''; document.getElementById('manualAnnClientList').style.display = 'none'; // Preencher fornecedores const supplierSel = document.getElementById('manualAnnSupplier'); const suppliers = window._fechadosSuppliers || []; supplierSel.innerHTML = '' + suppliers.map(s => ``).join(''); document.getElementById('modalManualAnn').hidden = false; }; // Busca cliente let _manualAnnSearchTimer = null; document.getElementById('manualAnnClientSearch').addEventListener('input', function() { clearTimeout(_manualAnnSearchTimer); _manualAnnSelectedClient = null; const q = this.value.trim(); if (q.length < 2) { document.getElementById('manualAnnClientResults').style.display = 'none'; return; } _manualAnnSearchTimer = setTimeout(async () => { try { const data = await get('/customers?q=' + encodeURIComponent(q)); const ul = document.getElementById('manualAnnClientResults'); const items = (data.customers || []).slice(0, 10); if (!items.length) { ul.style.display = 'none'; return; } ul.innerHTML = items.map(c => `
  • ${esc(c.name)}${c.phone ? `${esc(c.phone)}` : ''}
  • `).join(''); ul.style.display = 'block'; ul.querySelectorAll('li').forEach(li => { li.addEventListener('click', () => { _manualAnnSelectedClient = { id: li.dataset.id, name: li.dataset.name }; document.getElementById('manualAnnClientSearch').value = li.dataset.name; ul.style.display = 'none'; }); }); } catch(e) {} }, 250); }); // Adicionar cliente à lista document.getElementById('btnManualAnnAddClient').addEventListener('click', () => { const name = (_manualAnnSelectedClient?.name || document.getElementById('manualAnnClientSearch').value).trim(); if (!name) { toast('Informe o cliente', 'error'); return; } const qty = parseInt(document.getElementById('manualAnnClientQty').value) || 1; const client = { customer_id: _manualAnnSelectedClient?.id || null, customer_name: name, qty }; _manualAnnClients.push(client); _manualAnnSelectedClient = null; document.getElementById('manualAnnClientSearch').value = ''; document.getElementById('manualAnnClientQty').value = '1'; document.getElementById('manualAnnClientResults').style.display = 'none'; const ul = document.getElementById('manualAnnClientItems'); const idx = _manualAnnClients.length - 1; const li = document.createElement('li'); li.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-bottom:1px solid var(--border);font-size:13px'; li.innerHTML = `${esc(name)} ${qty} pç`; ul.appendChild(li); document.getElementById('manualAnnClientList').style.display = 'block'; }); // Preview foto document.getElementById('manualAnnPhoto').addEventListener('change', function() { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { _manualAnnPhotoB64 = e.target.result; document.getElementById('manualAnnPhotoImg').src = _manualAnnPhotoB64; document.getElementById('manualAnnPhotoPreview').style.display = 'block'; }; reader.readAsDataURL(file); }); // Fechar modal document.getElementById('modalManualAnn').addEventListener('click', e => { if (e.target === document.getElementById('modalManualAnn') || e.target.dataset.close !== undefined) { document.getElementById('modalManualAnn').hidden = true; } }); // Salvar document.getElementById('btnSaveManualAnn').addEventListener('click', async () => { const name = document.getElementById('manualAnnName').value.trim(); if (!name) { toast('Informe o nome do produto', 'error'); return; } if (!_manualAnnClients.length) { toast('Adicione ao menos um cliente', 'error'); return; } const btn = document.getElementById('btnSaveManualAnn'); btn.disabled = true; btn.textContent = '⏳'; try { await post('/announcements/manual', { product_name: name, sale_price: (parseInt(document.getElementById('manualAnnPrice').dataset.cents) || 0) / 100, fee_percent: parseFloat(document.getElementById('manualAnnFee').value) || 13, supplier_id: document.getElementById('manualAnnSupplier').value || null, images: _manualAnnPhotoB64 ? [_manualAnnPhotoB64] : [], clients: JSON.stringify(_manualAnnClients), }); document.getElementById('modalManualAnn').hidden = true; toast('✅ Anúncio manual criado!'); _lastFechadosData = null; _lastFechadosSignature = null; loadFechados(); } catch(e) { toast('Erro: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = '🛍️ Criar Anúncio'; } }); async function discardSubLot(ids, btn) { const qtd = Number(btn.dataset.gradeQtd || ids.length); if (!confirm(`Descartar ${qtd} reserva(s) desta grade incompleta?\n\nEsta ação é irreversível — os clientes serão removidos do lote.`)) return; btn.disabled = true; btn.textContent = '⏳'; try { await post('/announcements/intents/discard', { ids }); toast(`✅ ${qtd} reserva(s) descartada(s)!`); loadFechados(); } catch (e) { toast('Erro: ' + e.message, 'error'); btn.disabled = false; btn.textContent = '🗑️ Descartar'; } } async function arquivarLot(annId, round, btn) { if (!confirm(`Arquivar Lote #${round}? Ele irá para o Histórico.`)) return; btn.disabled = true; btn.textContent = '⏳'; try { await patch(`/announcements/${annId}/archive`, { round }); toast(`✅ Lote #${round} enviado para o Histórico!`); const lotCard = btn.closest('.lot-card'); lotCard?.remove(); } catch (e) { toast('Erro: ' + e.message, 'error'); btn.disabled = false; btn.textContent = '📋 Arquivar'; } } // Divide intents em sub-lotes de até gradeSize peças cada function splitIntoGrades(intents, gradeSize) { if (!gradeSize || gradeSize <= 0) return [intents]; // post_close=true vem PRIMEIRO (compras pós-fechamento ficam no topo da lista) const sorted = [...intents].sort((a, b) => { const aPos = a.post_close ? 1 : 0; const bPos = b.post_close ? 1 : 0; if (aPos !== bPos) return bPos - aPos; return new Date(a.created_at || 0) - new Date(b.created_at || 0); }); const batches = []; let current = [], currentQty = 0; for (const intent of sorted) { const iq = Number(intent.qty || 0); if (currentQty + iq > gradeSize && current.length > 0) { batches.push(current); current = []; currentQty = 0; } current.push(intent); currentQty += iq; if (currentQty >= gradeSize) { batches.push(current); current = []; currentQty = 0; } } if (current.length > 0) batches.push(current); return batches; } function renderIntentRow(i, fallbackUnitPrice = 0, fallbackFee = 13) { const initials = (i.customer_name || 'C').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); const isPaid = i.status === 'paid'; const isRefunded = i.status === 'refunded'; const isTransferred = i.status === 'transferred'; const transferFrom = (window.__intentTransfers?.from || {})[i.id]; // origem → pra quem foi const transferTo = (window.__intentTransfers?.to || {})[i.id]; // destino → de quem veio const rowClass = isPaid ? 'client-row client-row--paid' : isTransferred ? 'client-row client-row--transferred' : 'client-row'; const statusBadge = isPaid ? (i.end_to_end_id ? `✅ Pago Sicredi` : `✅ Pago Manual`) : isTransferred ? `🔄 REPASSADO` : isRefunded ? `↩ Estornado${Number(i.refund_amount) > 0 ? ' R$ ' + Number(i.refund_amount).toFixed(2).replace('.', ',') : ''}` : i.status === 'billed' ? `⏳ Cobrado` : `Pendente`; // Badges de repasse const transferFromBadge = transferFrom ? `
    ↪ Repassado para ${esc(transferFrom.to_name || 'Cliente')}
    ` : ''; const transferToBadge = transferTo ? `
    🆕 Recebeu repasse de ${esc(transferTo.from_name || 'Cliente')}
    ` : ''; // Botão de repasse: só pra intents pending/billed (não pago, não cancelado, não transferred) const canTransfer = ['pending', 'billed'].includes(i.status); const transferBtn = canTransfer ? `` : ''; const countInfo = i.billing_count > 1 ? `
    ${i.billing_count} cobranças enviadas
    ` : ''; const payToggle = (isPaid && i.end_to_end_id) || isRefunded ? `` : `
    Pago
    `; const postCloseBadge = (() => { if (!i.post_close) return ''; const dt = i.created_at ? new Date(i.created_at).toLocaleString('pt-BR', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' }) : ''; if (i.post_close_accepted) { // Aceito → verde, não clicável return `✓ POS${dt ? ' · ' + dt : ''}`; } // Não aceito → amarelo, clicável pra abrir confirmação return `⏰ POS${dt ? ' · ' + dt : ''}`; })(); return `
    ${esc(initials)}
    ${esc(i.customer_name || 'Cliente')}${postCloseBadge} ${i.customer_id ? `` : ''}
    ${transferFromBadge} ${transferToBadge} ${(() => { const feeVal = (i.fee_percent != null) ? i.fee_percent : fallbackFee; const fee = feeVal / 100; const feeLabel = Math.round(feeVal) + '%'; // Usa logic de detecção de preço robusta (sincronizada com backend) const unitPrice = parseAnnouncementPrice(esc(i.product_name || ''), i.unit_price, fallbackUnitPrice); const qty = Number(i.qty || 0); const base = unitPrice > 0 && qty > 0 ? unitPrice * qty : Number(i.total_amount || 0); const total = Math.round(base * (1 + fee) * 100) / 100; const color = esc(i.color_name || '—'); if (isTransferred) { return `
    ${color} · ${qty} peças × R$${unitPrice.toFixed(2).replace('.', ',')} (zerado)
    `; } const detalhe = unitPrice > 0 ? `${qty} peças × R$${unitPrice.toFixed(2).replace('.', ',')} + ${feeLabel} = R$ ${total.toFixed(2).replace('.', ',')}` : `R$ ${total.toFixed(2)}`; return `
    ${color} · ${detalhe}
    `; })()}
    ${isTransferred ? 0 : (i.qty || 0)} peças ${(i.status !== 'paid' && !isTransferred) ? `` : ''} ${transferBtn} ${(i.status !== 'paid' && !isTransferred) ? `` : ''}
    ${statusBadge} ${countInfo} ${isTransferred ? '' : payToggle}
    `; } // ── Modal: repasse de peças ────────────────────────────────────────────── let _mtiContext = null; // { intentId, customerName, qty, total } let _mtiSelectedCustomer = null; let _mtiSearchTimer = null; let _mtiListenersAttached = false; function _mtiEnsureListeners() { if (_mtiListenersAttached) return; _mtiListenersAttached = true; document.getElementById('btn-close-transfer').addEventListener('click', closeTransferModal); document.getElementById('btn-cancel-transfer').addEventListener('click', closeTransferModal); document.getElementById('modal-transfer-intent').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeTransferModal(); }); document.getElementById('mti-search').addEventListener('input', () => { clearTimeout(_mtiSearchTimer); _mtiSearchTimer = setTimeout(_mtiDoSearch, 250); }); const _mtiSelectRow = (row) => { document.querySelectorAll('.mti-result-row, .mti-pos-row').forEach(r => { r.style.background = r.classList.contains('mti-pos-row') ? 'rgba(245,158,11,.04)' : ''; r.style.borderLeft = ''; }); row.style.background = 'rgba(245,158,11,.22)'; row.style.borderLeft = '3px solid #f59e0b'; _mtiSelectedCustomer = { id: row.dataset.customerId, name: row.dataset.name, phone: row.dataset.phone, whatsapp_id: row.dataset.wa, }; document.getElementById('btn-confirm-transfer').disabled = false; }; document.getElementById('mti-results').addEventListener('click', (e) => { const row = e.target.closest('.mti-result-row'); if (row) _mtiSelectRow(row); }); document.getElementById('mti-pos-list').addEventListener('click', (e) => { const row = e.target.closest('.mti-pos-row'); if (row) _mtiSelectRow(row); }); document.getElementById('btn-confirm-transfer').addEventListener('click', _mtiConfirm); } function openTransferModal(intentId, customerName, qty, total) { _mtiEnsureListeners(); _mtiContext = { intentId, customerName, qty, total }; _mtiSelectedCustomer = null; const summary = document.getElementById('mti-summary'); summary.innerHTML = `
    🔄 Repassando peças de ${esc(customerName)}
    ${qty} peças · R$ ${Number(total || 0).toFixed(2).replace('.', ',')}
    ⚠️ ${esc(customerName)} será zerada e sairá do faturamento. O destinatário ficará devendo.
    `; document.getElementById('mti-search').value = ''; document.getElementById('mti-results').style.display = 'none'; document.getElementById('mti-results').innerHTML = ''; document.getElementById('mti-empty').style.display = ''; document.getElementById('btn-confirm-transfer').disabled = true; _mtiRenderPosCandidates(intentId); document.getElementById('modal-transfer-intent').removeAttribute('hidden'); setTimeout(() => document.getElementById('mti-search').focus(), 50); } function _mtiRenderPosCandidates(originIntentId) { const section = document.getElementById('mti-pos-section'); const list = document.getElementById('mti-pos-list'); const lots = window._currentFechadoLots || []; const pos = []; lots.forEach(lot => { (lot.intents || []).forEach(i => { if (i.id === originIntentId) return; if (!i.post_close) return; if (i.status === 'transferred' || i.status === 'cancelled' || i.status === 'paid') return; if (!i.customer_id) return; pos.push(i); }); }); const seen = new Set(); const uniquePos = pos.filter(i => { if (seen.has(i.customer_id)) return false; seen.add(i.customer_id); return true; }); if (!uniquePos.length) { section.style.display = 'none'; return; } section.style.display = ''; list.innerHTML = uniquePos.map(i => { const dt = i.created_at ? new Date(i.created_at).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; const accepted = i.post_close_accepted; const badge = accepted ? `✓ ACEITO` : `⏰ PENDENTE`; return `
    ${esc(i.customer_name || 'Cliente')} ${badge}
    ${i.qty} peças solicitadas · ${dt}
    `; }).join(''); } function closeTransferModal() { document.getElementById('modal-transfer-intent').setAttribute('hidden', ''); _mtiContext = null; _mtiSelectedCustomer = null; } async function _mtiDoSearch() { const q = document.getElementById('mti-search').value.trim(); const results = document.getElementById('mti-results'); const empty = document.getElementById('mti-empty'); if (q.length < 2) { results.style.display = 'none'; empty.style.display = ''; empty.textContent = 'Comece digitando o nome do cliente acima'; return; } empty.textContent = 'Buscando...'; try { const r = await get(`/customers?q=${encodeURIComponent(q)}`); const list = (r.customers || r.clientes || []).filter(c => c.id && c.name); if (!list.length) { results.style.display = 'none'; empty.style.display = ''; empty.textContent = 'Nenhum cliente encontrado'; return; } results.innerHTML = list.slice(0, 20).map(c => `
    ${esc(c.name)}
    ${esc(c.phone || c.whatsapp_id || '—')}
    `).join(''); results.style.display = ''; empty.style.display = 'none'; } catch (e) { empty.textContent = 'Erro: ' + e.message; results.style.display = 'none'; } } async function _mtiConfirm() { if (!_mtiContext || !_mtiSelectedCustomer) return; const btn = document.getElementById('btn-confirm-transfer'); btn.disabled = true; btn.textContent = '⏳ Repassando...'; try { await post(`/announcement-intents/${encodeURIComponent(_mtiContext.intentId)}/transfer`, { customer_id: _mtiSelectedCustomer.id }); toast(`✅ Repasse efetuado pra ${_mtiSelectedCustomer.name}`); closeTransferModal(); if (typeof openFechadoDetail === 'function' && window._currentFechadoAnnId) { openFechadoDetail(window._currentFechadoAnnId); } else { location.reload(); } } catch (e) { toast('Erro: ' + (e.message || e), 'error'); btn.disabled = false; btn.textContent = 'Confirmar repasse'; } } function renderLotCard(lot) { // Preço unitário do lote (fallback: sale_price do anúncio) — intents antigos podem não ter unit_price salvo const lotUnitPrice = Number(lot.sale_price) || 0; const lotFee = (lot.fee_percent != null) ? lot.fee_percent : 13; const lotFeeMultiplier = 1 + lotFee / 100; // Sort defensivo: post_close=true SEMPRE primeiro (mesmo se cache antigo trouxer ordem errada) if (Array.isArray(lot.intents)) { lot.intents.sort((a, b) => { const aPos = a.post_close ? 1 : 0; const bPos = b.post_close ? 1 : 0; if (aPos !== bPos) return bPos - aPos; return new Date(a.created_at || 0) - new Date(b.created_at || 0); }); } const pending = lot.intents.filter(i => i.status === 'pending'); const billed = lot.intents.filter(i => i.status === 'billed'); const paid = lot.intents.filter(i => i.status === 'paid'); const allPaid = pending.length === 0 && billed.length === 0; const statusParts = [ pending.length ? `${pending.length} pendente${pending.length > 1 ? 's' : ''}` : '', billed.length ? `${billed.length} cobrado${billed.length > 1 ? 's' : ''}` : '', paid.length ? `${paid.length} pago${paid.length > 1 ? 's' : ''}` : '', ].filter(Boolean).join(' · '); const anyBilled = billed.length > 0 || paid.length > 0; const allBilled = pending.length === 0; const pendingNames = pending.map(i => i.customer_name).join(', '); const billingTimestamps = lot.intents.filter(i => i.billing_sent_at).map(i => new Date(i.billing_sent_at)); const lastBilling = billingTimestamps.length > 0 ? new Date(Math.max(...billingTimestamps)) : null; const lastBillingStr = lastBilling ? lastBilling.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; const billedPeople = lot.intents.filter(i => (i.billing_count || 0) > 0).length; const billingCountLabel = billedPeople > 0 ? ` · Cobrado ${billedPeople} pessoa${billedPeople !== 1 ? 's' : ''}` : ''; const createdDate = lot.created_at ? new Date(lot.created_at) : null; const createdDateStr = createdDate ? createdDate.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; const creationHtml = createdDateStr ? `
    📅 Criado em: ${createdDateStr}
    ` : ''; let billingMetaHtml = ''; if (allBilled) { billingMetaHtml = `
    ✅ Pacote já cobrado ${lastBillingStr ? '(' + lastBillingStr + ')' : ''}${billingCountLabel}
    ${creationHtml}`; } else if (anyBilled) { billingMetaHtml = `
    ⚠️ Cobrança enviada, falta cobrar:${esc(pendingNames)}
    ${creationHtml}`; } else { billingMetaHtml = `
    ⚠️ Nenhuma cobrança enviada ainda
    ${creationHtml}`; } // Alerta de clientes com OUTRAS compras pendentes const clientsWithExtras = lot.intents.filter(i => i.has_extras && i.status !== 'paid'); const extrasWarningHtml = clientsWithExtras.length > 0 ? `
    🚨 ${clientsWithExtras.map(c => `${esc(c.customer_name)} + Compras Pendentes`).join(' · ')}
    ` : ''; // ── Dividir em grades ────────────────────────────────────────────── const gradeSize = lot.target_qty ?? 0; const grades = splitIntoGrades(lot.intents, gradeSize); const useGradeLayout = grades.length > 1; let bodyHtml; if (useGradeLayout) { bodyHtml = grades.map((gradeIntents, gi) => { const isLast = gi === grades.length - 1; const gradeQty = gradeIntents.reduce((s, i) => s + Number(i.qty || 0), 0); const isFull = gradeQty >= gradeSize; const gradeIds = gradeIntents.map(i => i.id).join(','); const gradeRows = [...gradeIntents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)).map(i => renderIntentRow(i, lotUnitPrice, lotFee)).join(''); const gradeLabel = isFull ? `📦 Grade ${gi + 1} ${gradeQty} peças` : `⚠️ Grade ${gi + 1} — Incompleta ${gradeQty}/${gradeSize} peças`; const borderColor = isLast && !isFull ? '#f59e0b' : '#334155'; const bgColor = isLast && !isFull ? 'rgba(245,158,11,0.05)' : 'transparent'; return `
    ${gradeLabel}
    ${isLast && !isFull ? `` : ''}
    ${gradeRows}
    `; }).join(''); } else { const rows = [...lot.intents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)).map(i => renderIntentRow(i, lotUnitPrice, lotFee)).join(''); bodyHtml = `
    👥 ${lot.intents.length} cliente${lot.intents.length > 1 ? 's' : ''}
    ${rows}
    `; } const gradeLabel = gradeSize > 0 ? `🎯 Grade ${gradeSize}` : `∞ Sem Limite`; const gradeNextQty = gradeSize > 0 ? 0 : 24; return `
    Lote #${lot.round} ${statusParts} ${billingMetaHtml}
    ${(() => { const recalc = lot.intents .filter(i => i.status !== 'cancelled') .reduce((s, i) => { const uPrice = parseAnnouncementPrice(lot.product_name, i.unit_price, lot.sale_price); const qty = Number(i.qty) || 0; const fee = (i.fee_percent != null) ? (1 + i.fee_percent / 100) : lotFeeMultiplier; const base = (uPrice > 0 && qty > 0) ? uPrice * qty : Number(i.total_amount || 0) / fee; return s + base * fee; }, 0); return 'R$ ' + recalc.toFixed(2); })()} ${allPaid ? `` : ``}
    ${extrasWarningHtml}
    ${bodyHtml}
    `; } async function estornarLot(annId, round, btn) { if (!confirm(`Estornar Lote #${round} de volta para Anúncios Fechados?\n\nAtenção: os pagamentos serão apagados e os intents voltarão para Pendente.`)) return; btn.disabled = true; btn.textContent = '⏳'; try { await patch(`/announcements/${annId}/unarchive`, { round }); toast(`↩ Lote #${round} estornado para Fechados.`); btn.closest('.historico-lot')?.remove(); } catch (e) { toast('Erro: ' + e.message, 'error'); btn.disabled = false; btn.textContent = '↩ Estornar'; } } async function restaurarLot(annId, round, btn) { if (!confirm(`Restaurar Lote #${round} para Anúncios Fechados e Central de Pedidos?\n\nOs pagamentos já realizados serão mantidos.`)) return; btn.disabled = true; btn.textContent = '⏳'; try { await patch(`/announcements/${annId}/restore`, { round }); toast(`🔄 Lote #${round} restaurado para Fechados com pagamentos mantidos.`); btn.closest('.historico-lot')?.remove(); } catch (e) { toast('Erro: ' + e.message, 'error'); btn.disabled = false; btn.textContent = '🔄 Restaurar'; } } function printLot(lotId) { const lot = _historicoLots[lotId]; if (!lot) { toast('Dados do lote não encontrados. Recarregue o histórico.', 'error'); return; } _printLotCurrent = lot; const seqTag = lot.seq ? ` #${String(lot.seq).padStart(2, '0')}` : ''; const dateStr = lot.archived_at ? new Date(lot.archived_at).toLocaleDateString('pt-BR') : new Date().toLocaleDateString('pt-BR'); document.getElementById('printLotTitle').textContent = `🖨️ ${lot.product_name}${seqTag} — Lote #${lot.round}`; const rows = (lot.intents || []).map(i => { const stLabel = i.status === 'paid' ? '✅ Pago' : i.status === 'billed' ? '⏳ Cobr.' : '—'; const colorTxt = i.color_name ? `
    ${esc(i.color_name)}` : ''; return ` ${esc(i.customer_name || 'Cliente')}${colorTxt} ${i.qty} R$ ${Number(i.total_amount).toFixed(2)} ${stLabel} `; }).join(''); const withWA = (lot.intents || []).filter(i => i.wa_sender_id).length; document.getElementById('printLotBody').innerHTML = `
    MODA FÁCIL
    Comprovante de Venda
    Produto:${esc(lot.product_name)}${esc(seqTag)}
    Lote:#${lot.round}
    Grupo:${esc(lot.wa_group_name || '—')}
    Data:${dateStr}
    ${rows}
    Cliente / CorQtdValorStatus
    TOTAL:R$ ${Number(lot.total_amount || 0).toFixed(2)}
    ${withWA > 0 ? `

    ${withWA} de ${(lot.intents || []).length} cliente(s) com WhatsApp registrado.

    ` : `

    Nenhum cliente tem WhatsApp registrado neste lote.

    `}`; const waBtn = document.getElementById('btnSendReceiptWA'); waBtn.disabled = withWA === 0; waBtn.onclick = () => doSendReceiptWA(lot); document.getElementById('btnDoPrint').onclick = () => doPrintLot(lot); openModal('modalPrintLot'); } function doPrintLot(lot) { const seqTag = lot.seq ? ` #${String(lot.seq).padStart(2, '0')}` : ''; const dateStr = lot.archived_at ? new Date(lot.archived_at).toLocaleDateString('pt-BR') : new Date().toLocaleDateString('pt-BR'); const rows = (lot.intents || []).map(i => { const stLabel = i.status === 'paid' ? 'Pago' : i.status === 'billed' ? 'Cobrado' : i.status === 'refunded' ? 'Estornado' : 'Pendente'; return ` ${i.customer_name || 'Cliente'}${i.color_name ? '
    ' + i.color_name + '' : ''} ${i.qty} peças R$ ${Number(i.total_amount).toFixed(2)} ${stLabel} `; }).join(''); const win = window.open('', '_blank', 'width=420,height=650'); win.document.write(` Comprovante — ${lot.product_name}${seqTag} Lote #${lot.round}

    MODA FÁCIL

    Comprovante de Venda
    Produto:${lot.product_name}${seqTag}
    Lote:#${lot.round}
    Grupo:${lot.wa_group_name || '—'}
    Data:${dateStr}
    ${rows}
    Cliente / CorQtdValorStatus
    TOTAL:R$ ${Number(lot.total_amount || 0).toFixed(2)}
    `); win.document.close(); setTimeout(() => win.print(), 300); } async function doSendReceiptWA(lot) { const btn = document.getElementById('btnSendReceiptWA'); const withWA = (lot.intents || []).filter(i => i.wa_sender_id).length; if (!withWA) { toast('Nenhum cliente tem WhatsApp registrado', 'warning'); return; } if (!confirm(`Enviar comprovante por WhatsApp para ${withWA} cliente(s)?`)) return; btn.disabled = true; btn.textContent = '⏳ Enviando...'; try { const [annId, roundStr] = lot.id.split('::'); const res = await post('/announcements/lots/send-receipt', { announcement_id: annId, round: parseInt(roundStr), }); toast(`✅ Comprovante enviado para ${res.sent} cliente(s)!${res.failed > 0 ? ` (${res.failed} sem WA)` : ''}`); closeModal('modalPrintLot'); } catch (e) { toast('Erro ao enviar: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = '📲 Enviar WA'; } } // ── Modal de Cobrança ───────────────────────────────────────────────────── let _billingLotId = null; let _billingIntents = []; // filterIds: quando vem de "Cobrar Grade", pré-seleciona só os clientes daquela grade async function openBillingModal(lotId, filterIds = null) { // Encontrar o lot nos dados já carregados const card = document.querySelector(`[data-lot-id="${lotId}"]`); if (!card) return; // Recarregar dados frescos do lote e seus intents const [lotsData, intentsData] = await Promise.all([ get('/announcements/fechados').catch(() => null), get(`/announcements/lots/${lotId}/intents`).catch(() => null) ]); const lot = lotsData?.lots?.find(l => l.id === lotId); if (!lot) { toast('Lote não encontrado', 'error'); return; } _billingLotId = lotId; _billingIntents = (intentsData?.intents || []).filter(i => i.status === 'pending' || i.status === 'billed'); const seqTag = lot.seq ? ` #${String(lot.seq).padStart(2, '0')}` : ''; const gradeLabel = filterIds ? ` · Grade selecionada` : ''; document.getElementById('billingTitle').textContent = `💳 Cobrar — ${lot.product_name}${seqTag} · Lote #${lot.round}${gradeLabel}`; // Render checkboxes — pré-selecionar apenas os da grade clicada (se filterIds fornecido) const list = document.getElementById('billingClientList'); list.innerHTML = _billingIntents.map(i => { const isBilled = i.status === 'billed'; // Pre-selecionar todos por padrão (se filterIds, apenas os da grade) const preChecked = filterIds ? filterIds.includes(i.id) : true; const fee = (i.fee_percent ?? 13) / 100; const feeLabel = Math.round(i.fee_percent ?? 13) + '%'; const unitPrice = Number(i.unit_price || 0); const qty = Number(i.qty || 0); const base = unitPrice > 0 && qty > 0 ? unitPrice * qty : Number(i.total_amount || 0); const billingAmt = Math.round(base * (1 + fee) * 100) / 100; const detalhe = unitPrice > 0 ? `${qty} peças × R$${unitPrice.toFixed(2).replace('.', ',')} + ${feeLabel}` : `${qty} peça${qty !== 1 ? 's' : ''}`; return ` `; }).join(''); document.getElementById('chkSelectAll').checked = true; updateBillingTotal(); // Reset estado do aviso de extras _billingDecisions = {}; window._lastBillingExtras = null; document.getElementById('billingExtrasWarning').hidden = true; document.getElementById('btnConfirmBilling').textContent = '📲 Enviar Cobranças'; list.querySelectorAll('.billing-chk').forEach(chk => chk.addEventListener('change', updateBillingTotal)); document.getElementById('btnConfirmBilling').onclick = confirmBilling; openModal('modalBilling'); // Verificação automática de extras (cross-check) para todos no modal const allIds = _billingIntents.map(i => i.id); if (allIds.length) { post('/announcements/billing/cross-check', { intent_ids: allIds }).then(check => { if (check.has_extras) { window._lastBillingExtras = check.extras; _billingDecisions = {}; check.extras.forEach(ex => { _billingDecisions[ex.wa_sender_id] = 'only'; }); renderBillingExtras(check.extras); } }).catch(err => console.warn('[AUTO-CHECK] Erro:', err.message)); } } function onSelectAll(e) { document.querySelectorAll('.billing-chk').forEach(chk => { chk.checked = e.target.checked; }); updateBillingTotal(); } function updateBillingTotal() { const chks = [...document.querySelectorAll('.billing-chk:checked')]; const total = chks.reduce((s, c) => s + parseFloat(c.dataset.amount || 0), 0); document.getElementById('billingTotalVal').textContent = money(total); // Sync select-all const all = document.querySelectorAll('.billing-chk').length; document.getElementById('chkSelectAll').checked = chks.length === all && all > 0; } // Decisão por cliente quando há extras: 'only' ou 'all' (default: 'only') let _billingDecisions = {}; // wa_sender_id → 'only' | 'all' async function confirmBilling() { const selected = [...document.querySelectorAll('.billing-chk:checked')].map(c => c.value); if (!selected.length) { toast('Selecione pelo menos um cliente', 'warning'); return; } // Se o aviso já está visível → executar com as decisões escolhidas if (!document.getElementById('billingExtrasWarning').hidden) { await executeBillingWithDecisions(selected); return; } const btn = document.getElementById('btnConfirmBilling'); btn.disabled = true; btn.textContent = '⏳ Verificando...'; try { const check = await post('/announcements/billing/cross-check', { intent_ids: selected }); if (!check.has_extras) { await executeBilling({ intent_ids: selected }); return; } // Salvar extras e inicializar decisões como 'only' window._lastBillingExtras = check.extras; _billingDecisions = {}; check.extras.forEach(ex => { _billingDecisions[ex.wa_sender_id] = 'only'; }); renderBillingExtras(check.extras); btn.textContent = '📲 Confirmar e Enviar'; } catch (e) { toast('Erro: ' + e.message, 'error'); } finally { btn.disabled = false; } } function renderBillingExtras(extras) { const list = document.getElementById('billingExtrasList'); list.innerHTML = extras.map(ex => { const extraLines = ex.extra_intents.map(ei => `
    📦 ${esc(ei.product_name)}
    Lote #${ei.round}${ei.color_name ? ` · ${esc(ei.color_name)}` : ''} · ${ei.qty} peças · R$ ${Number(ei.total_amount).toFixed(2)}
    ` ).join(''); const waId = ex.wa_sender_id; return `
    👤 ${esc(ex.customer_name)}
    ${extraLines}
    Total acumulado: R$ ${ex.grand_total.toFixed(2)}
    `; }).join(''); // Adicionar listeners APÓS o innerHTML (módulo ES não pode usar onchange inline) list.querySelectorAll('input[type="radio"]').forEach(radio => { const waId = radio.closest('[data-wa-id]')?.dataset.waId; if (waId) radio.addEventListener('change', () => { _billingDecisions[waId] = radio.value; }); }); document.getElementById('billingExtrasWarning').hidden = false; } async function executeBillingWithDecisions(selected) { const intentWaMap = {}; _billingIntents.forEach(i => { intentWaMap[i.id] = i.wa_sender_id; }); const normalIds = []; const groups = []; const groupedWaIds = new Set(Object.keys(_billingDecisions)); for (const id of selected) { if (!groupedWaIds.has(intentWaMap[id])) normalIds.push(id); } // Montar payload conforme decisão por cliente const extrasData = _billingDecisions; const extrasMap = {}; // Reconstruir mapa de extras a partir do DOM document.querySelectorAll('#billingExtrasList [data-wa-id]').forEach(el => { extrasMap[el.dataset.waId] = JSON.parse(el.dataset.extraIds || '[]'); }); // Pegar extras do _billingExtrasData que foi salvo no check const extrasFromCheck = window._lastBillingExtras || []; for (const ex of extrasFromCheck) { const decision = extrasData[ex.wa_sender_id] || 'only'; const mainIds = selected.filter(id => intentWaMap[id] === ex.wa_sender_id); if (decision === 'all') { groups.push({ wa_sender_id: ex.wa_sender_id, main_intent_ids: mainIds, extra_intent_ids: ex.extra_intents.map(ei => ei.id), }); } else { normalIds.push(...mainIds); } } await executeBilling({ intent_ids: normalIds, groups }); } async function executeBilling(payload) { const btn = document.getElementById('btnConfirmBilling'); btn.disabled = true; btn.textContent = '⏳ Enviando...'; try { const res = await post('/announcements/billing', payload); const msg = `✅ ${res.sent} cobrança${res.sent !== 1 ? 's' : ''} enviada${res.sent !== 1 ? 's' : ''}!`; toast(res.failed > 0 ? msg + ` (${res.failed} com erro)` : msg); _billingDecisions = {}; window._lastBillingExtras = null; document.getElementById('billingExtrasWarning').hidden = true; closeModal('modalBilling'); await loadFechados(); } catch (e) { toast('Erro: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = '📲 Enviar Cobranças'; } } // ── Marcar PIX Recebido ─────────────────────────────────────────────────── async function markPix(intentId, btn) { if (!confirm('Confirmar o recebimento?')) return; const isCheckbox = btn.tagName === 'INPUT' && btn.type === 'checkbox'; btn.disabled = true; if (isCheckbox) { btn.checked = true; // garante que o slider fica ligado durante a request } else { btn.textContent = '⏳'; } try { const result = await patch(`/announcements/intents/${intentId}/pay`); // Captura referências ANTES de re-renderizar const row = btn.closest('.client-row'); const lotCard = btn.closest('.lot-card'); // Atualiza visualmente a linha do cliente: verde + ajusta badge if (row) { row.classList.add('client-row--paid'); const badge = row.querySelector('[class^="status-"]'); if (badge) { badge.className = 'status-paid'; badge.textContent = '✅ Pago Manual'; } // Botão clássico ("✓ PIX") some depois do pagamento. Checkbox NÃO some // — fica ligado até o re-render trazer o estado real do servidor. if (!isCheckbox) btn.remove(); } if (result?.auto_archived) { // Lote inteiro pago → remove o lot-card do DOM toast('🎉 Todos pagos! Lote arquivado automaticamente no Histórico.'); lotCard?.remove(); } else { toast('✅ PIX confirmado! Pedido criado no Conferente.'); } const _ct = document.querySelector('.tab-btn.active')?.dataset.tab; if (_ct === 'fechados') await loadFechados(); else if (_ct === 'fornecedores') await loadFornecedoresTab(); else await loadAtivos(); } catch (e) { toast('Erro: ' + e.message, 'error'); if (isCheckbox) { btn.checked = false; btn.disabled = false; } else { btn.disabled = false; btn.textContent = '✓ Pago'; } } } // ── Marcar Pago via SWITCH (otimista: UI muda na hora, save em background) ─ async function markPixOptimistic(intentId, input) { if (!confirm('Confirmar o recebimento?')) { input.checked = false; // reverte visual (usuário clicou pra ligar mas cancelou) return; } // Atualização visual IMEDIATA antes da request input.dataset.pending = '1'; const row = input.closest('.client-row'); const labelEl = input.closest('.toggle-wrap')?.querySelector('.toggle-label'); const badge = row?.querySelector('[class^="status-"]'); const prev = { rowPaid: row?.classList.contains('client-row--paid'), badgeClass: badge?.className, badgeText: badge?.textContent, labelColor: labelEl?.style.color, }; if (row) row.classList.add('client-row--paid'); if (badge){ badge.className = 'status-paid'; badge.textContent = '✅ Pago Manual'; } if (labelEl) labelEl.style.color = '#10b981'; try { await patch(`/announcements/intents/${intentId}/pay`); toast('✅ Recebimento confirmado'); } catch (e) { // Reverte tudo input.checked = false; if (row && !prev.rowPaid) row.classList.remove('client-row--paid'); if (badge && prev.badgeClass) { badge.className = prev.badgeClass; badge.textContent = prev.badgeText; } if (labelEl) labelEl.style.color = prev.labelColor; toast('Erro: ' + e.message, 'error'); } finally { delete input.dataset.pending; } } // ── Reverter pagamento via SWITCH (otimista) ────────────────────────────── async function unmarkPixOptimistic(intentId, input) { if (!confirm('Reverter o pagamento?')) { input.checked = true; // reverte visual (usuário clicou pra desligar mas cancelou) return; } input.dataset.pending = '1'; const row = input.closest('.client-row'); const right = row?.querySelector('.client-right'); const labelEl = input.closest('.toggle-wrap')?.querySelector('.toggle-label'); // Busca o badge DENTRO de .client-right pra evitar pegar badges de outra coisa const badge = right?.querySelector('span.status-paid, span.status-billed, span.status-pending'); const prev = { rowPaid: row?.classList.contains('client-row--paid'), badgeClass: badge?.className, badgeText: badge?.textContent, labelColor: labelEl?.style.color, }; // Otimista: mostra como pendente já. Após sucesso ajusta pra "Cobrado" se foi o caso. if (row) row.classList.remove('client-row--paid'); if (badge){ badge.className = 'status-pending'; badge.textContent = 'Pendente'; } if (labelEl) labelEl.style.color = 'var(--text-muted)'; try { const r = await patch(`/announcements/intents/${intentId}/unpay`); if (badge && r.new_status === 'billed') { badge.className = 'status-billed'; badge.textContent = '⏳ Cobrado'; } toast('✅ Cancelamento confirmado'); } catch (e) { // Reverte tudo input.checked = true; if (row && prev.rowPaid) row.classList.add('client-row--paid'); if (badge && prev.badgeClass) { badge.className = prev.badgeClass; badge.textContent = prev.badgeText; } if (labelEl) labelEl.style.color = prev.labelColor; toast('Erro: ' + e.message, 'error'); } finally { delete input.dataset.pending; } } // ── Reverter pagamento (admin errou e quer voltar pra pendente) ─────────── async function unmarkPix(intentId, input) { if (!confirm('Reverter este pagamento?\n\nO cliente voltará para cobrado/pendente e o pedido no Conferente será cancelado. Use só se marcou errado.')) { input.checked = true; // reverte visual return; } input.disabled = true; try { const r = await patch(`/announcements/intents/${intentId}/unpay`); toast(r.new_status === 'billed' ? '↩ Pagamento revertido. Cliente voltou para "Cobrado".' : '↩ Pagamento revertido. Cliente voltou para "Pendente".'); const _ct = document.querySelector('.tab-btn.active')?.dataset.tab; if (_ct === 'fechados') await loadFechados(); else if (_ct === 'fornecedores') await loadFornecedoresTab(); else await loadAtivos(); } catch (e) { input.checked = true; // reverte visual em caso de erro toast('Erro: ' + e.message, 'error'); } finally { input.disabled = false; } } function setupRecount(annId, round) { const btn = document.getElementById('btnRecountVotes'); if (!btn) return; btn.onclick = async () => { if (!confirm('Deseja buscar votos no histórico do WhatsApp deste grupo?\n\nIsso pode ajudar a recuperar votos que não foram registrados automaticamente.')) return; btn.disabled = true; const oldText = btn.innerHTML; btn.innerHTML = '⏳ Recontando...'; try { const res = await post(`/announcements/${annId}/recount-poll`, { round: round }); alert(`Sincronização concluída!\n${res.count || 0} votos processados do histórico.`); // Recarregar os dados showPurchaseList(annId, round); loadAtivos(); loadFechados(); } catch (err) { alert('Erro ao sincronizar: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = oldText; } }; } // ── Lista de Compra ─────────────────────────────────────────────────────── async function showPurchaseList(annId, round) { try { const query = round ? `?round=${round}` : ''; const [annData, intentsData] = await Promise.all([ get(`/announcements/${annId}`), get(`/announcements/${annId}/intents${query}`), ]); const ann = annData.announcement || annData; const intents = (intentsData.intents || []).sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const targetRound = intentsData.round || round || ann.round || 1; const productName = ann.product_name || 'Anúncio'; const totalQty = intents.reduce((s, i) => s + (i.qty || 1), 0); document.getElementById('purchaseListTitle').textContent = `📋 Lista #${targetRound} — ${productName}`; const body = document.getElementById('purchaseListBody'); if (!intents.length) { body.innerHTML = `

    Nenhum pedido ainda no lote #${targetRound}.

    `; // Configurar botão de recontagem mesmo se vazio setupRecount(annId, targetRound); openModal('modalPurchaseList'); return; } setupRecount(annId, targetRound); const isPacoteGrp = (ann.wa_group_name || '').toUpperCase().includes('PACOTE'); const BATCH_COLORS = ['#6366f1','#10b981','#f59e0b','#ec4899','#14b8a6','#ef4444']; let batchesHtml, metaLabel; if (isPacoteGrp) { // PACOTE: mesmo algoritmo do Fechar — sort desc + overflow check const sorted24 = [...intents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)); const batches = []; let cur = { intents: [], qty: 0 }; for (const intent of sorted24) { const iq = Number(intent.qty || 1); if (cur.qty + iq > 24 && cur.intents.length > 0) { batches.push({ ...cur, complete: cur.qty === 24 }); cur = { intents: [], qty: 0 }; } cur.intents.push(intent); cur.qty += iq; if (cur.qty >= 24) { batches.push({ ...cur, complete: cur.qty === 24 }); cur = { intents: [], qty: 0 }; } } if (cur.intents.length) batches.push({ ...cur, complete: false }); metaLabel = `${intents.length} pedido${intents.length !== 1 ? 's' : ''} · ${batches.length} lote${batches.length !== 1 ? 's' : ''}`; let globalIdx = 0; batchesHtml = batches.map((batch, bi) => { const color = BATCH_COLORS[bi % BATCH_COLORS.length]; const label = batch.complete ? `Lote ${bi + 1} — ${batch.qty} peças` : `Restante — ${batch.qty} peças (incompleto)`; const rows = batch.intents.map(i => { const dt = new Date(i.created_at); const dateTime = dt.toLocaleDateString('pt-BR') + ' ' + dt.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); globalIdx++; return `
    ${globalIdx}.
    ${esc(i.customer_name || 'Cliente')}
    ${dateTime}
    ${i.qty || 1} peças
    `; }).join(''); return `
    ${label}${batch.qty} peças total
    ${rows}
    `; }).join(''); } else { // LIVRE: lista única sem divisão por lotes metaLabel = `${intents.length} pedido${intents.length !== 1 ? 's' : ''} · lista única`; const color = BATCH_COLORS[0]; let idx = 0; const rows = [...intents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)).map(i => { const dt = new Date(i.created_at); const dateTime = dt.toLocaleDateString('pt-BR') + ' ' + dt.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); idx++; return `
    ${idx}.
    ${esc(i.customer_name || 'Cliente')}
    ${dateTime}
    ${i.qty || 1} peças
    `; }).join(''); batchesHtml = `
    Lista completa${totalQty} peças total
    ${rows}
    `; } body.innerHTML = `
    ${metaLabel} ${totalQty} peças
    ${batchesHtml}
    `; // Botão imprimir — agrupa em lotes de 24 peças, cada um num retângulo document.getElementById('btnPrintPurchaseList').onclick = () => { // Preço unitário — aceita "R$60", "$60", "R$ 60,00" ou só "60" const _text = ann.product_name || ''; const _priceM = _text.match(/R?\$\s*(\d+(?:[.,]\d{1,2})?)/) || _text.match(/\b(\d+(?:[.,]\d{1,2})?)\b/); const _unitPrice = _priceM ? parseFloat(_priceM[1].replace(',', '.')) : (ann.sale_price > 0 ? ann.sale_price : 0); const _FEE = 0.13; const fmt = v => 'R$ ' + v.toFixed(2).replace('.', ','); const BATCH_BORDER_COLORS = ['#6366f1','#10b981','#f59e0b','#ec4899','#14b8a6','#ef4444']; const BATCH_BG_COLORS = ['#eef2ff','#ecfdf5','#fffbeb','#fdf2f8','#f0fdfa','#fef2f2']; const hasPrice = _unitPrice > 0; // Montar batches: PACOTE → mesmo algoritmo do Fechar (sort desc + overflow); LIVRE → lista única const printBatches = []; if (isPacoteGrp) { const sorted24p = [...intents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)); let cur = { intents: [], qty: 0 }; for (const intent of sorted24p) { const iq = Number(intent.qty || 1); if (cur.qty + iq > 24 && cur.intents.length > 0) { printBatches.push({ ...cur, complete: cur.qty === 24 }); cur = { intents: [], qty: 0 }; } cur.intents.push(intent); cur.qty += iq; if (cur.qty >= 24) { printBatches.push({ ...cur, complete: cur.qty === 24 }); cur = { intents: [], qty: 0 }; } } if (cur.intents.length) printBatches.push({ ...cur, complete: false }); } else { const sortedLivre = [...intents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)); printBatches.push({ intents: sortedLivre, qty: totalQty, complete: true, singleList: true }); } let globalIdx = 0; const batchesHtml = printBatches.map((batch, bi) => { const border = BATCH_BORDER_COLORS[bi % BATCH_BORDER_COLORS.length]; const bg = BATCH_BG_COLORS[bi % BATCH_BG_COLORS.length]; const batchTotal = hasPrice ? batch.intents.reduce((s, i) => s + (i.qty || 1) * _unitPrice * (1 + _FEE), 0) : 0; const rows = batch.intents.map(i => { const dt = new Date(i.created_at); const dateTime = dt.toLocaleDateString('pt-BR') + ' ' + dt.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const valor = hasPrice ? fmt((i.qty || 1) * _unitPrice * (1 + _FEE)) : '—'; globalIdx++; return ` ${globalIdx} ${i.customer_name || 'Cliente'} ${dateTime} ${i.qty || 1} peças ${valor} `; }).join(''); const label = batch.singleList ? `Lista completa — ${batch.qty} peças` : batch.complete ? `Lote ${bi + 1} — ${batch.qty} peças` : `Restante — ${batch.qty} peças (incompleto)`; const tfootValorCell = hasPrice ? `${fmt(batchTotal)}` : `sem preço`; return `
    ${label}
    ${rows} ${tfootValorCell}
    # Cliente Data/Hora Qtd Valor +13%
    Total ${batch.qty} peças
    `; }).join(''); // Total geral em R$ const grandTotal = _unitPrice > 0 ? intents.reduce((s, i) => s + (i.qty || 1) * _unitPrice * (1 + _FEE), 0) : 0; const grandTotalHtml = _unitPrice > 0 ? ` · Total: ${fmt(grandTotal)}` : ` · ⚠ preço não definido`; const priceInfoHtml = _unitPrice > 0 ? ` · ${fmt(_unitPrice)}/peça + 13% taxa` : ''; // Montar galeria de fotos (se houver) const images = ann.images || []; const photosHtml = images.length ? `
    ${images.map(img => ``).join('')}
    ` : ''; const w = window.open('', '_blank', 'width=720,height=950'); w.document.write(` Lista — ${productName}

    ${productName}

    Lista de pedidos · ${new Date().toLocaleDateString('pt-BR')} · ${intents.length} pedidos · ${totalQty} peças${priceInfoHtml}${grandTotalHtml}

    ${photosHtml} ${batchesHtml} `); w.document.close(); w.focus(); setTimeout(() => w.print(), 400); }; openModal('modalPurchaseList'); } catch (e) { toast('Erro: ' + e.message, 'error'); } } function logLabel(type) { return { pickup: 'Retirada', bus: 'Ônibus', postal: 'Correios' }[type] || type; } // ── Estado interno ──────────────────────────────────────────────────────── let _closeBatchAnnId = null; let _closeBatchState = null; // ── Editar Anúncio ──────────────────────────────────────────────────────── let _editAnnId = null; let _editAnnPhotos = []; let _editAnnPhotosChanged = false; let _suppliers = []; async function loadSuppliers() { try { const data = await get('/suppliers'); _suppliers = data.suppliers || []; } catch { _suppliers = []; } } function populateSupplierSelect(selectedId) { const sel = document.getElementById('editSupplier'); sel.innerHTML = '' + _suppliers.map(s => ``).join(''); } function setupSupplierControls() { const addBtn = document.getElementById('btnAddSupplier'); const row = document.getElementById('newSupplierRow'); const confirmBtn = document.getElementById('btnConfirmSupplier'); const cancelBtn = document.getElementById('btnCancelSupplier'); const nameInput = document.getElementById('newSupplierName'); addBtn.onclick = () => { row.style.display = 'flex'; nameInput.value = ''; nameInput.focus(); }; cancelBtn.onclick = () => { row.style.display = 'none'; }; confirmBtn.onclick = async () => { const name = nameInput.value.trim(); if (!name) { toast('Informe o nome do fornecedor', 'error'); return; } confirmBtn.disabled = true; confirmBtn.textContent = '⏳'; try { const data = await post('/settings/suppliers', { name }); await loadSuppliers(); populateSupplierSelect(data.id || data.supplier?.id); row.style.display = 'none'; toast('Fornecedor cadastrado!'); } catch (e) { toast('Erro: ' + e.message, 'error'); } finally { confirmBtn.disabled = false; confirmBtn.textContent = 'Salvar'; } }; } function updateEditGradeStyle() { const g24 = document.getElementById('editGrade24').checked; document.getElementById('editLblGrade24').style.background = g24 ? 'rgba(99,102,241,.1)' : 'rgba(255,255,255,.04)'; document.getElementById('editLblGrade24').style.borderColor = g24 ? 'rgba(99,102,241,.4)' : 'var(--border)'; document.getElementById('editLblSemLimite').style.background = !g24 ? 'rgba(99,102,241,.1)' : 'rgba(255,255,255,.04)'; document.getElementById('editLblSemLimite').style.borderColor = !g24 ? 'rgba(99,102,241,.4)' : 'var(--border)'; } async function openEditAnnModal(annId) { const data = await get(`/announcements/${annId}`).catch(() => null); if (!data?.announcement) { toast('Erro ao carregar anúncio', 'error'); return; } const ann = data.announcement; _editAnnId = annId; _editAnnPhotos = ann.images ? [...ann.images] : []; _editAnnPhotosChanged = false; document.getElementById('editPhotosWrap').innerHTML = ''; document.getElementById('editPhotosInput').value = ''; renderEditPhotoPreviews(); document.getElementById('editPrice').value = ann.sale_price; document.getElementById('editFee').value = ann.fee_percent ?? 13; const isGrade = (ann.target_qty ?? 0) > 0; document.getElementById('editGrade24').checked = isGrade; document.getElementById('editSemLimite').checked = !isGrade; updateEditGradeStyle(); document.querySelectorAll('input[name="editGrade"]').forEach(r => r.addEventListener('change', updateEditGradeStyle)); document.getElementById('editNotes').value = ann.notes ?? ''; // Fornecedor await loadSuppliers(); populateSupplierSelect(ann.supplier_id || ''); document.getElementById('newSupplierRow').style.display = 'none'; setupSupplierControls(); const updatePreview = () => { const p = parseFloat(document.getElementById('editPrice').value) || 0; const f = parseFloat(document.getElementById('editFee').value) || 13; if (p > 0) { document.getElementById('editPricePerPiece').textContent = money(p * (1 + f / 100)); document.getElementById('editPricePreview').style.display = ''; } else document.getElementById('editPricePreview').style.display = 'none'; }; updatePreview(); document.getElementById('editPrice').oninput = updatePreview; document.getElementById('editFee').oninput = updatePreview; document.getElementById('btnSaveEdit').onclick = saveEditAnn; // Configurar input de fotos document.getElementById('editPhotosInput').onchange = (e) => { const files = Array.from(e.target.files || []); files.forEach(file => { const reader = new FileReader(); reader.onload = async (ev) => { const compressed = await compressImage(ev.target.result, 1024, 0.8); if (compressed) { _editAnnPhotos.push(compressed); _editAnnPhotosChanged = true; renderEditPhotoPreviews(); } }; reader.readAsDataURL(file); }); e.target.value = ''; }; openModal('modalEditAnn'); } function renderEditPhotoPreviews() { const wrap = document.getElementById('editPhotosWrap'); wrap.innerHTML = _editAnnPhotos.map((src, i) => `
    `).join(''); wrap.querySelectorAll('.photo-thumb-del').forEach(btn => { btn.addEventListener('click', () => { _editAnnPhotos.splice(Number(btn.dataset.idx), 1); _editAnnPhotosChanged = true; renderEditPhotoPreviews(); }); }); } async function saveEditAnn() { const btn = document.getElementById('btnSaveEdit'); btn.disabled = true; btn.textContent = '⏳'; try { const photos = _editAnnPhotos.filter(Boolean); const collage = (_editAnnPhotosChanged && photos.length > 0) ? await createImageCollage(photos) : null; const imagesPayload = _editAnnPhotosChanged ? { images: collage ? [collage] : [] } : {}; await patch(`/announcements/${_editAnnId}`, { sale_price: parseFloat(document.getElementById('editPrice').value), fee_percent: parseFloat(document.getElementById('editFee').value), target_qty: document.getElementById('editGrade24').checked ? 24 : 0, notes: document.getElementById('editNotes').value.trim() || null, ...imagesPayload, supplier_id: document.getElementById('editSupplier').value || null, }); toast('✅ Anúncio atualizado!'); if (_editAnnPhotosChanged) _thumbCache.delete(_editAnnId); closeModal('modalEditAnn'); const _ct2 = document.querySelector('.tab-btn.active')?.dataset.tab; if (_ct2 === 'fornecedores') await loadFornecedoresTab(); else await loadAtivos(); } catch (e) { toast('Erro: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = '💾 Salvar'; } } // ── Apagar Anúncio ──────────────────────────────────────────────────────── async function deleteAnn(id, name) { if (!confirm(`Deseja apagar o anúncio "${name}" permanentemente?\nEsta ação não pode ser desfeita.`)) return; try { await del(`/announcements/${id}`); toast('Anúncio excluído!'); const activeTab = document.querySelector('.tab-btn.active')?.dataset.tab; if (activeTab === 'fechados') { _lastFechadosData = null; _lastFechadosSignature = null; loadFechados(); } else { loadAtivos(); } } catch (e) { toast(e.message, 'error'); } } // Apagar um LOTE específico (ex: falta de estoque) async function deleteLot(lotId, round) { if (!confirm(`Deseja excluir permanentemente todas as vendas do Lote #${round} deste anúncio?\n\nEsta ação não pode ser desfeita.`)) return; try { await del(`/announcements/lots/${lotId}`); toast(`Lote #${round} excluído!`); document.getElementById('modalFechadoDetail').hidden = true; loadFechados(); } catch (e) { toast('Erro ao excluir lote: ' + e.message, 'error'); } } async function deleteIntent(intentId, customerName, annId) { if (!confirm(`Deseja excluir permanentemente o pedido de "${customerName}"?\n\nEsta ação não pode ser desfeita.`)) return; try { await del(`/announcements/${annId}/intents/${intentId}`); toast('Pedido excluído com sucesso!'); const fechadoModalOpen = !document.getElementById('modalFechadoDetail').hidden; // Recarrega dados em background; se modal de detalhes estiver aberto, re-renderiza // ele mesmo (sem trocar de tela) await loadFechados(true); if (fechadoModalOpen) { openFechadoDetail(annId); } loadAtivos(); } catch (e) { toast('Erro ao excluir: ' + e.message, 'error'); } } // Cores dos lotes (cicla se tiver mais de 6) const BATCH_COLORS = [ { bg: 'rgba(99,102,241,.13)', border: '#6366f1', label: '#818cf8' }, { bg: 'rgba(16,185,129,.13)', border: '#10b981', label: '#34d399' }, { bg: 'rgba(245,158,11,.13)', border: '#f59e0b', label: '#fbbf24' }, { bg: 'rgba(236,72,153,.13)', border: '#ec4899', label: '#f472b6' }, { bg: 'rgba(20,184,166,.13)', border: '#14b8a6', label: '#2dd4bf' }, { bg: 'rgba(239,68,68,.13)', border: '#ef4444', label: '#f87171' }, ]; async function closePackage(annId) { _closeBatchAnnId = annId; // Buscar intents do round atual const card = document.querySelector(`[data-ann-id="${annId}"]`); const productName = card?.querySelector('.ann-card__title')?.textContent?.split('🛍️')[1]?.trim() || 'Anúncio'; const body = document.getElementById('closeBatchBody'); document.getElementById('closeBatchTitle').textContent = `📦 Fechar — ${productName}`; body.innerHTML = '
    Carregando votos...
    '; openModal('modalCloseBatch'); try { const data = await get(`/announcements/${annId}/intents`); const intents = (data.intents || []).sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); if (!intents.length) { body.innerHTML = '
    Nenhum voto registrado.
    '; return; } const targetQty = data.target_qty ?? 0; if (targetQty === 0) { // Sem Limite: lista única + botão único renderSingleClose(annId, intents); return; } // Grade N: ordenar por qty desc para preencher lotes de forma exata const sortedIntents = [...intents].sort((a, b) => (Number(b.qty) || 0) - (Number(a.qty) || 0) || new Date(a.created_at) - new Date(b.created_at)); const batches = []; let current = { intents: [], qty: 0 }; for (const intent of sortedIntents) { const iq = Number(intent.qty || 1); // Se adicionar este intent ultrapassar a grade, fechar lote atual primeiro if (current.qty + iq > targetQty && current.intents.length > 0) { batches.push({ ...current, complete: current.qty === targetQty }); current = { intents: [], qty: 0 }; } current.intents.push(intent); current.qty += iq; if (current.qty >= targetQty) { batches.push({ ...current, complete: current.qty === targetQty }); current = { intents: [], qty: 0 }; } } if (current.intents.length) batches.push({ ...current, complete: false }); renderBatchCards(annId, batches, targetQty); } catch (e) { body.innerHTML = `
    ${esc(e.message)}
    `; } } function renderSingleClose(annId, intents) { const body = document.getElementById('closeBatchBody'); const totalQty = intents.reduce((s, i) => s + (i.qty || 1), 0); const color = BATCH_COLORS[0]; const names = intents.map(i => `
    ${esc(i.customer_name || 'Cliente')}
    ${i.qty} pç
    `).join(''); body.innerHTML = `
    Lista completa ${intents.length} pedidos · ${totalQty} peças
    ${names}
    `; body.querySelectorAll('[data-discard-id]').forEach(btn => { btn.addEventListener('click', async (e) => { const el = e.currentTarget; const name = el.closest('div')?.querySelector('span')?.textContent?.trim() || 'este cliente'; if (!confirm(`Remover ${name} do lote?`)) return; const id = el.dataset.discardId; el.disabled = true; try { await post('/announcements/intents/discard', { ids: [id] }); await closePackage(_closeBatchAnnId); } catch (err) { toast(err.message, 'error'); el.disabled = false; } }); }); document.getElementById('btnSingleClose').addEventListener('click', async () => { const btn = document.getElementById('btnSingleClose'); btn.disabled = true; btn.textContent = '⏳'; try { await post(`/announcements/${annId}/close-batch`, { batch_intent_ids: intents.map(i => i.id), remainder_action: 'keep', }); toast('✅ Anúncio fechado!'); closeModal('modalCloseBatch'); await loadAtivos(); } catch (e) { toast(e.message, 'error'); btn.disabled = false; btn.textContent = '📦 Fechar Pacote'; } }); } function renderBatchCards(annId, batches, targetQty = 24) { const body = document.getElementById('closeBatchBody'); const completeBatches = batches.filter(b => b.complete); const rem = batches.find(b => !b.complete); if (!completeBatches.length && (!rem || !rem.intents.length)) { body.innerHTML = '
    Nenhum voto para fechar.
    '; return; } if (!completeBatches.length) { const color = BATCH_COLORS[0]; const names = rem.intents.map(i => `
    ${esc(i.customer_name || 'Cliente')} ${i.qty} peças
    `).join(''); body.innerHTML = `
    ⚠️ Sem lotes completos — faltam ${24 - rem.qty} peças para o 1º lote
    🗑️ Serão descartados (${rem.qty} peças)
    ${names}
    `; return; } const allIntents = batches.flatMap(b => b.intents); const savedKey = 'mf_batch_order_' + annId; let restored = false; try { const saved = localStorage.getItem(savedKey); if (saved) { const parsed = JSON.parse(saved); const sLots = parsed.lots || []; const sExcl = parsed.exclusion || parsed.remainder || []; // backward compat const byId = Object.fromEntries(allIntents.map(i => [i.id, i])); const savedIds = new Set([...sLots.flat(), ...sExcl]); const newIntents = allIntents.filter(i => !savedIds.has(i.id)); _closeBatchState = { annId, targetQty, lots: sLots.map(ids => ({ intents: ids.map(id => byId[id]).filter(Boolean) })).filter(l => l.intents.length > 0), exclusion: [...sExcl.map(id => byId[id]).filter(Boolean), ...newIntents], }; if (_closeBatchState.lots.length > 0) restored = true; } } catch (e) { localStorage.removeItem(savedKey); } if (!restored) { _closeBatchState = { annId, targetQty, lots: completeBatches.map(b => ({ intents: [...b.intents] })), exclusion: rem ? [...rem.intents] : [], }; } _renderBatchUI(); } function _renderBatchUI() { const { lots, exclusion, targetQty = 24 } = _closeBatchState; const body = document.getElementById('closeBatchBody'); const totalAccepted = lots.reduce((s, l) => s + l.intents.reduce((a, i) => a + i.qty, 0), 0); const exclQty = exclusion.reduce((s, i) => s + i.qty, 0); let html = `
    ${lots.length} pacote${lots.length !== 1 ? 's' : ''} · ${totalAccepted} pç aceitas ${exclusion.length ? `· ${exclQty} pç na exclusão` : ''} · Arraste para reordenar
    `; lots.forEach((lot, li) => { const color = BATCH_COLORS[li % BATCH_COLORS.length]; const lotQty = lot.intents.reduce((s, i) => s + i.qty, 0); const rows = lot.intents.map(it => `
    ${esc(it.customer_name || 'Cliente')}
    ${it.qty} pç
    `).join(''); html += `
    Pacote ${li + 1} ${lot.intents.length} pedidos · ${lotQty} pç
    ${rows || '
    — Lote vazio —
    '}
    🚫 Solte aqui para excluir
    `; }); if (exclusion.length) { const rows = exclusion.map(it => `
    ${esc(it.customer_name || 'Cliente')} ${it.qty} pç
    `).join(''); html += `
    🚫 Área de Exclusão — não serão cobrados nem contabilizados
    ${exclQty}/${targetQty} pç
    ${rows}
    `; } html += `
    `; body.innerHTML = html; // ── Drag-and-drop ────────────────────────────────────────────────────── let _dragId = null, _dragFrom = null; body.addEventListener('dragstart', e => { const el = e.target.closest('[data-intent-id]'); if (!el) return; _dragId = el.dataset.intentId; _dragFrom = el.dataset.fromZone; e.dataTransfer.effectAllowed = 'move'; setTimeout(() => el.style.opacity = '0.4', 0); }); body.addEventListener('dragend', e => { const el = e.target.closest('[data-intent-id]'); if (el) el.style.opacity = ''; }); body.querySelectorAll('.batch-drop-zone').forEach(zone => { zone.addEventListener('dragover', e => { if (e.target.closest('.lot-excl-drop')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; zone.style.boxShadow = '0 0 0 2px rgba(255,255,255,.5)'; }); zone.addEventListener('dragleave', e => { if (!zone.contains(e.relatedTarget)) zone.style.boxShadow = ''; }); zone.addEventListener('drop', e => { if (e.target.closest('.lot-excl-drop')) return; e.preventDefault(); zone.style.boxShadow = ''; const toZone = zone.dataset.zone; if (!_dragId || toZone === _dragFrom) return; _moveBatchIntent(_dragId, _dragFrom, toZone); }); }); // Strips de exclusão por lote body.querySelectorAll('.lot-excl-drop').forEach(strip => { strip.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; strip.style.boxShadow = '0 0 0 2px rgba(239,68,68,.7)'; strip.style.background = 'rgba(239,68,68,.18)'; }); strip.addEventListener('dragleave', e => { if (!strip.contains(e.relatedTarget)) { strip.style.boxShadow = ''; strip.style.background = 'rgba(239,68,68,.05)'; } }); strip.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); strip.style.boxShadow = ''; strip.style.background = 'rgba(239,68,68,.05)'; if (!_dragId || _dragFrom === 'exclusion') return; _moveBatchIntent(_dragId, _dragFrom, 'exclusion'); }); }); // × delete permanente body.querySelectorAll('[data-discard-id]').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); const el = e.currentTarget; const name = el.closest('[data-intent-id]')?.querySelector('span[style*="flex:1"]')?.textContent?.trim() || 'este cliente'; if (!confirm(`Remover ${name} permanentemente do anúncio?`)) return; const id = el.dataset.discardId; el.disabled = true; try { await post('/announcements/intents/discard', { ids: [id] }); await closePackage(_closeBatchAnnId); } catch (err) { toast(err.message, 'error'); el.disabled = false; } }); }); document.getElementById('btnSalvarOrdem').addEventListener('click', () => { const key = 'mf_batch_order_' + _closeBatchState.annId; localStorage.setItem(key, JSON.stringify({ lots: _closeBatchState.lots.map(l => l.intents.map(i => i.id)), exclusion: _closeBatchState.exclusion.map(i => i.id), })); const btn = document.getElementById('btnSalvarOrdem'); btn.textContent = '✅ Salvo!'; setTimeout(() => { if (btn) btn.textContent = '💾 Salvar Alterações'; }, 2000); }); document.getElementById('btnFecharTudo').addEventListener('click', async () => { const btn = document.getElementById('btnFecharTudo'); const batch_intent_ids = _closeBatchState.lots.flatMap(l => l.intents.map(i => i.id)); const discard_ids = _closeBatchState.exclusion.map(i => i.id); btn.disabled = true; btn.textContent = '⏳'; try { await post(`/announcements/${_closeBatchState.annId}/close-batch`, { batch_intent_ids, discard_ids, }); localStorage.removeItem('mf_batch_order_' + _closeBatchState.annId); const n = _closeBatchState.lots.length; toast(`✅ ${n} lote${n > 1 ? 's' : ''} fechado${n > 1 ? 's' : ''}!`); closeModal('modalCloseBatch'); await loadAtivos(); } catch (e) { toast(e.message, 'error'); btn.disabled = false; btn.textContent = '📦 Fechar Pacote'; } }); } function _moveBatchIntent(intentId, fromZone, toZone) { const state = _closeBatchState; let intent = null; if (fromZone === 'exclusion') { const idx = state.exclusion.findIndex(i => i.id === intentId); if (idx === -1) return; [intent] = state.exclusion.splice(idx, 1); } else { const li = parseInt(fromZone.replace('lot-', ''), 10); const idx = state.lots[li]?.intents.findIndex(i => i.id === intentId) ?? -1; if (idx === -1) return; [intent] = state.lots[li].intents.splice(idx, 1); } if (toZone === 'exclusion') { state.exclusion.push(intent); } else { const li = parseInt(toZone.replace('lot-', ''), 10); if (state.lots[li]) state.lots[li].intents.push(intent); else state.exclusion.push(intent); } _renderBatchUI(); } // ── 🎉 Celebração ───────────────────────────────────────────────────────── function playVictorySound() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); [523.25, 659.25, 783.99, 1046.5].forEach((freq, i) => { const osc = ctx.createOscillator(), gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'triangle'; osc.frequency.value = freq; const t = ctx.currentTime + i * 0.13; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.3, t + 0.04); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); osc.start(t); osc.stop(t + 0.4); }); } catch (e) { } } // Fila de celebrações para exibir uma por vez let _celebQueue = []; let _celebShowing = false; function showCelebrationPopup(items) { _celebQueue.push(...items); if (!_celebShowing) _nextCelebration(); } function _nextCelebration() { if (!_celebQueue.length) { _celebShowing = false; return; } _celebShowing = true; const item = _celebQueue.shift(); const popup = document.getElementById('celebrationPopup'); // Definir conteúdo const milestone = item.milestone; document.getElementById('celebPopMilestone').textContent = milestone ?? '🎊'; document.getElementById('celebPopName').textContent = item.name; // Emoji muda conforme o milestone const emojis = { 24: '🎉', 48: '🔥', 72: '🚀', 96: '💎', 120: '👑' }; document.getElementById('celebPopEmoji').textContent = (milestone && emojis[milestone]) || '🎊'; popup.hidden = false; popup.style.display = 'flex'; // Fechar ao clicar const close = () => { popup.hidden = true; popup.style.display = ''; popup.removeEventListener('click', close); setTimeout(_nextCelebration, 300); }; popup.addEventListener('click', close); // Auto-fechar em 6s setTimeout(close, 6000); } function launchConfetti() { const canvas = document.getElementById('confettiCanvas'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; canvas.style.display = 'block'; const ctx = canvas.getContext('2d'); const COLORS = ['#f43f5e', '#f59e0b', '#10b981', '#6366f1', '#8b5cf6', '#06b6d4', '#facc15', '#ec4899']; const particles = Array.from({ length: 180 }, () => ({ x: Math.random() * canvas.width, y: Math.random() * canvas.height * 0.5 - canvas.height * 0.3, w: Math.random() * 10 + 5, h: Math.random() * 5 + 3, color: COLORS[Math.floor(Math.random() * COLORS.length)], vx: (Math.random() - 0.5) * 7, vy: Math.random() * 4 + 1, rot: Math.random() * 360, rotV: (Math.random() - 0.5) * 12, opacity: 1, })); let frame = 0; function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); let alive = false; particles.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.vx *= 0.99; p.rot += p.rotV; if (frame > 80) p.opacity -= 0.012; if (p.opacity > 0) alive = true; ctx.save(); ctx.globalAlpha = Math.max(0, p.opacity); ctx.translate(p.x, p.y); ctx.rotate(p.rot * Math.PI / 180); ctx.fillStyle = p.color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.restore(); }); frame++; if (alive && frame < 250) requestAnimationFrame(animate); else { ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.style.display = 'none'; } } animate(); } // ── Mini modal: edição rápida de cliente ───────────────────────────────── let _mceCustomerId = null; function openClientEditModal(customerId, customerName) { _mceCustomerId = customerId; document.getElementById('mce-name').value = customerName || ''; document.getElementById('mce-phone').value = ''; // Carregar dados reais do cliente get(`/customers/${customerId}`).then(c => { if (c) { document.getElementById('mce-name').value = c.name || customerName || ''; document.getElementById('mce-phone').value = c.phone || ''; } }).catch(() => {}); document.getElementById('modal-client-edit').removeAttribute('hidden'); setTimeout(() => document.getElementById('mce-name').select(), 50); } function closeClientEditModal() { document.getElementById('modal-client-edit').setAttribute('hidden', ''); _mceCustomerId = null; } // Atribuição inline de fornecedor direto no card async function setAnnSupplier(sel) { const annId = sel.dataset.annId; const supplierId = sel.value || null; const prevVal = sel.dataset.prev || ''; sel.dataset.prev = sel.value; try { await patch(`/announcements/${annId}`, { supplier_id: supplierId }); sel.classList.toggle('has-supplier', !!supplierId); if (supplierId) { toast('Fornecedor salvo', 'success'); } } catch(e) { sel.value = prevVal; toast('Erro ao salvar fornecedor', 'error'); } } window.setAnnSupplier = setAnnSupplier; // Expor funções para o escopo global (usadas em eventos onclick inline) window.openEditAnnModal = openEditAnnModal; window.deleteAnn = deleteAnn; window.loadAtivos = loadAtivos; window.loadFornecedoresTab = loadFornecedoresTab; window.loadPedidosTab = loadPedidosTab; window.openSupplierModalTab = openSupplierModalTab; window.saveSupplierTab = saveSupplierTab; window.deleteSupplierTab = deleteSupplierTab; window.openPrintSupplierModal = openPrintSupplierModal; window.doPrintSupplierList = doPrintSupplierList; window.openClientEditModal = openClientEditModal; window.closeClientEditModal = closeClientEditModal; window.selectTab = switchTab; // ── RECONTAR GLOBAL ─────────────────────────────────────────────────────── document.getElementById('btnGlobalRecount')?.addEventListener('click', async () => { const currentTab = document.querySelector('.tab-btn.active')?.dataset.tab; if (currentTab !== 'fechados') { toast('A recontagem funciona apenas na aba de Anúncios Fechados.', 'warning'); return; } let lotsToRecount = [...document.querySelectorAll('#annGridFechados [data-recount-poll]')] .map(btn => ({ annId: btn.dataset.recountPoll, round: btn.dataset.recountRound })) .filter(b => b.annId && b.round); const uniqueLots = []; const seen = new Set(); for (const lot of lotsToRecount) { const key = `${lot.annId}::${lot.round}`; if (!seen.has(key)) { seen.add(key); uniqueLots.push(lot); } } if (!uniqueLots.length) { toast('Nenhum anúncio para recontar na aba atual.'); return; } if (!confirm(`Deseja iniciar a recontagem de TODOS os ${uniqueLots.length} lotes fechados exibidos?\n\nIsso sincronizará eventuais votos perdidos e pode demorar alguns minutos.`)) return; const btn = document.getElementById('btnGlobalRecount'); btn.disabled = true; const originalText = btn.innerHTML; try { let totalCount = 0; let fails = 0; toast(`Iniciando recontagem de ${uniqueLots.length} lotes...`); for (let i = 0; i < uniqueLots.length; i++) { btn.innerHTML = `⏳ ${i + 1}/${uniqueLots.length}`; try { const res = await post(`/announcements/${uniqueLots[i].annId}/recount-poll`, { round: uniqueLots[i].round }); totalCount += (res.count || 0); } catch (err) { fails++; console.error('[Recontagem] Erro no lote', uniqueLots[i], err); } } const msg = fails === 0 ? `✅ Recontagem concluída! ${totalCount} novos votos contabilizados.` : `⚠️ Concluído com ${fails} falha(s). ${totalCount} votos contabilizados.`; alert(msg); await loadFechados(); } catch (e) { toast('Erro geral na recontagem: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = originalText; } }); // ── CENTRAL DE PEDIDOS ────────────────────────────────────────────────── async function loadPedidosTab(isBackground = false) { const wrap = document.getElementById('annGridPedidos'); const head = document.getElementById('pedidosHeader'); if (_lastPedidosData && !isBackground) { _renderPedidosUI(_lastPedidosData); } else if (!wrap.innerHTML || wrap.querySelector('.mf-spinner')) { wrap.className = ''; wrap.innerHTML = `
    Carregando pedidos...
    `; } try { const { lots, suppliers } = await get('/conferente/lots'); const newSig = lots.map(l => `${l.lot_key}:${l.status}:${l.total_qty}`).sort().join('|'); if (newSig === _lastPedidosSignature && _lastPedidosData && isBackground) { if (head.hidden) _renderPedidosUI(_lastPedidosData); return; } _lastPedidosSignature = newSig; _lastPedidosData = { lots, suppliers }; _renderPedidosUI(_lastPedidosData); } catch (err) { if (!isBackground) { wrap.innerHTML = `

    Erro: ${err.message}

    `; } } } function _renderPedidosUI(data) { const wrap = document.getElementById('annGridPedidos'); const head = document.getElementById('pedidosHeader'); let lots = data.lots || []; window.pedidosLots = lots; // Guardar para o modal // Filtro de busca (reusando o annSearchInput) if (_annSearchQuery) { const q = _annSearchQuery.toLowerCase().trim(); lots = lots.filter(l => (l.product_name || '').toLowerCase().includes(q) || (l.seq && `#${l.seq}`.toLowerCase().includes(q)) || (l.seq && String(l.seq).includes(q)) ); } lots.forEach(l => { if (l.product_name) l.product_name = stripEmojis(l.product_name); }); // Data efetiva do lote: created_at (quando entrou no pipeline) ou updated_at const lotDate = l => l.created_at || l.updated_at || null; // Ordena por DIA (mais recente primeiro) e dentro do mesmo dia por seq ASC (#945, #947, #953...) const dayKey = l => { const dt = lotDate(l); return dt ? new Date(dt).toISOString().slice(0, 10) : ''; }; lots.sort((a, b) => { const dayDiff = (dayKey(b) || '').localeCompare(dayKey(a) || ''); if (dayDiff !== 0) return dayDiff; return (Number(a.seq) || 0) - (Number(b.seq) || 0); }); // Mapear dias do calendário (CHAVES y-m-d dentro da janela) const _pwin = _pedWindowRange(); const _pwinStart = _pwin.start.getTime(), _pwinEnd = _pwin.end.getTime(); _pedDaysSet.clear(); lots.forEach(l => { const dt = lotDate(l); if (dt) { const t = new Date(dt).getTime(); if (t >= _pwinStart && t <= _pwinEnd) _pedDaysSet.add(_dkeyOf(dt)); } }); // 1º load: seleciona o dia de hoje (se tiver pedidos) ou o mais recente — fica verde if (_pedFirstLoad) { _pedFirstLoad = false; const hjKey = _dkeyOf(new Date()); if (_pedDaysSet.has(hjKey)) { _pedCalDay = hjKey; } else { const keys = [..._pedDaysSet]; _pedCalDay = keys.length ? keys.sort((a, b) => _dkeyToDate(a) - _dkeyToDate(b)).pop() : null; } } // Quando search está ativa, mostra tudo. Sem dia selecionado → janela inteira. const visibleLots = _annSearchQuery ? lots : (_pedCalDay === null ? lots.filter(l => { const dt = lotDate(l); if (!dt) return false; const t = new Date(dt).getTime(); return t >= _pwinStart && t <= _pwinEnd; }) : lots.filter(l => { const dt = lotDate(l); return dt && _dkeyOf(dt) === _pedCalDay; })); head.hidden = false; head.innerHTML = `
    Aguardando Fornecedor
    Recebido/Separando
    Conferido/Pronto
    Expedido
    `; { const _c = document.getElementById('tabCountPedidos'); if (_c) _c.textContent = visibleLots.length || ''; } renderPedidosCalendar(); if (!visibleLots.length) { const _pSel = _pedCalDay ? _dkeyToDate(_pedCalDay) : null; wrap.innerHTML = `
    🚚

    ${_pSel ? `Sem pedidos em ${String(_pSel.getDate()).padStart(2,'0')}/${String(_pSel.getMonth()+1).padStart(2,'0')}.` : 'Nenhum pedido encontrado.'}

    `; return; } wrap.className = 'kanban-board'; let html = ''; let lastDayKey = null; const LOT_STATUS = { received: { label: 'Aguardando Fornecedor', color: 'blue' }, ready_to_pick: { label: 'Recebido/Separando', color: 'orange' }, picking: { label: 'Em separação', color: 'orange' }, ready: { label: 'Pronto', color: 'green' }, dispatched: { label: 'Expedido', color: 'red' }, }; for (const lot of visibleLots) { const lotDt = lotDate(lot); if (lotDt) { const d = new Date(lotDt); const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; if (dayKey !== lastDayKey) { const label = d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' }); html += `
    ${label}
    `; lastDayKey = dayKey; } } const meta = LOT_STATUS[lot.status] || LOT_STATUS.received; const clrMap = { blue: '#3b82f6', orange: '#f97316', green: '#10b981', red: '#ef4444' }; const color = clrMap[meta.color] || '#3b82f6'; const STATUS_STEPS = ['received','ready_to_pick','ready','dispatched']; const curIdx = STATUS_STEPS.indexOf(lot.status === 'picking' ? 'ready_to_pick' : lot.status); const prevStatus = curIdx > 0 ? STATUS_STEPS[curIdx - 1] : null; const prevLabel = prevStatus ? (LOT_STATUS[prevStatus]?.label || prevStatus) : null; const seqTag = lot.seq ? `#${String(lot.seq).padStart(2, '0')}` : (lot.announcement_id?.slice(-6) || ''); const roundTag = lot.round > 1 ? ` /R${lot.round}` : ''; const supplierName = lot.supplier_name || ''; const _cachedThumb = lot.images?.[0] || _thumbCache.get(lot.announcement_id) || ''; const mainImage = `
    ${_cachedThumb ? `` : '🚚'}
    `; html += `
    ${mainImage}
    ${esc(lot.product_name || 'Sem nome')}
    ${lot.is_manual ? `🛍️ Manual` : ''}
    ${lot.total_buyers || 0} clientes · ${lot.total_qty || 0} peças
    ${supplierName ? `
    ${esc(supplierName)}
    ` : ''}
    `; } wrap.innerHTML = html; // Lazy-load thumbnails em background const annIds = [...new Set(visibleLots.map(l => l.announcement_id).filter(Boolean))]; lazyLoadThumbnailsInto(wrap, annIds, (annId, thumb, raw) => { (window.pedidosLots || []).forEach(l => { if (l.announcement_id === annId) { l.images = [thumb]; l.fullImage = raw || thumb; } }); }); } function renderPedidosCalendar() { const container = document.getElementById('calendarWrapPedidos'); if (!container) return; const months = ['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ']; const weekDays = ['DOM','SEG','TER','QUA','QUI','SEX','SÁB']; const now = new Date(); const todayMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const _realMonth = now.getMonth(), _realYear = now.getFullYear(); let html = `
    `; let _pickRendered = false; _pedStripDays().forEach(({ y, m, d, boundary }) => { const key = _dkey(y, m, d); const dt = new Date(y, m, d); const wDay = weekDays[dt.getDay()]; const isActive = _pedCalDay === key; const isFuture = dt.getTime() > todayMs; const hasAnns = _pedDaysSet.has(key); const isDimmed = isFuture || !hasAnns; if (boundary) { const isCurMonth = (m === _realMonth && y === _realYear); const _col = isCurMonth ? '#10b981' : 'var(--text-muted)'; if (!_pickRendered) { _pickRendered = true; html += `
    ${months[m]}
    `; } else if (isCurMonth) { html += `
    ${months[m]}
    `; } else { html += `
    ${months[m]}
    `; } } html += `
    ${String(d).padStart(2, '0')} ${wDay}
    `; }); html += `
    `; container.innerHTML = html; const _pedSel = document.getElementById('pedCalMonthSelect'); if (_pedSel) _pedSel.addEventListener('change', e => { const v = e.target.value; if (v === 'win') { _pedMonthPicked = false; const n = new Date(); _pedCalMonth = n.getMonth(); _pedCalYear = n.getFullYear(); } else { _pedMonthPicked = true; _pedCalMonth = parseInt(v, 10); } _pedCalDay = null; loadPedidosTab(); }); container.querySelectorAll('.day-item').forEach(el => { el.addEventListener('click', () => { const key = el.dataset.daykey; _pedCalDay = (_pedCalDay === key) ? null : key; if (_pedCalDay !== null) { const wrap = document.getElementById('annGridPedidos'); const sep = wrap?.querySelector(`.day-separator[data-daykey="${_pedCalDay}"]`); if (sep) sep.scrollIntoView({ behavior: 'smooth', block: 'start' }); } renderPedidosCalendar(); loadPedidosTab(); }); }); // Centraliza o dia ativo na barra (sem rolar a página) centerActiveDay(container); } const _LOT_STATUS_MAP = { received: { label: 'Aguardando Fornecedor', color: '#3b82f6', next: 'ready_to_pick', nextLabel: '→ Recebeu mercadoria' }, ready_to_pick: { label: 'Recebido/Separando', color: '#f97316', next: 'ready', nextLabel: '→ Separação e Conferência' }, picking: { label: 'Em separação', color: '#f97316', next: 'ready', nextLabel: '→ Separação e Conferência' }, ready: { label: 'Conferido/Pronto', color: '#10b981', next: 'dispatched', nextLabel: '→ Marcar como Expedido' }, dispatched: { label: 'Expedido', color: '#ef4444', next: null, nextLabel: null }, }; window._mld_lotKey = null; window._mld_lot = null; window._mld_intents = []; window._mld_status = null; window.openConferenteModal = async function(lotKey) { const lot = (window.pedidosLots || []).find(l => l.lot_key === lotKey); if (!lot) return; window._mld_lotKey = lotKey; window._mld_lot = lot; window._mld_status = lot.status; const seqTag = lot.seq ? `#${String(lot.seq).padStart(2,'0')}` : ''; const roundTag = lot.round > 1 ? ` /R${lot.round}` : ''; document.getElementById('mld-title').textContent = lot.product_name || 'Sem nome'; document.getElementById('mld-subtitle').textContent = [seqTag, roundTag].filter(Boolean).join('') || ''; // Foto da peça const photoWrap = document.getElementById('mld-photo-wrap'); const thumb = lot.images?.[0] || _thumbCache.get(lot.announcement_id) || ''; const fullImg = lot.fullImage || lot.images?.[0] || thumb; photoWrap.innerHTML = (thumb || fullImg) ? `` : '📦'; // Fornecedor const supplierEl = document.getElementById('mld-supplier'); if (lot.supplier_name) { supplierEl.textContent = '🏭 ' + lot.supplier_name; supplierEl.style.display = 'inline-block'; } else { supplierEl.style.display = 'none'; } _renderLotStatusBar(lotKey, lot.status); document.getElementById('mld-stats').innerHTML = ` 👥 ${lot.total_buyers || 0} clientes 📦 ${lot.total_qty || 0} peças ${money(lot.total_amount || 0)}`; document.getElementById('mld-intents').innerHTML = `
    Carregando pedidos...
    `; document.getElementById('modalLotDetail').removeAttribute('hidden'); document.body.style.overflow = 'hidden'; try { const data = await get(`/conferente/lots/${encodeURIComponent(lotKey)}/intents`); window._mld_intents = data.intents || []; _renderLotIntents(window._mld_intents); } catch(e) { document.getElementById('mld-intents').innerHTML = `
    Erro: ${esc(e.message)}
    `; } }; window.closeLotModal = function() { document.getElementById('modalLotDetail').setAttribute('hidden', ''); document.body.style.overflow = ''; window._mld_lotKey = null; window._mld_lot = null; window._mld_intents = []; window._mld_status = null; }; window.toggleIntentPick = async function(intentId, card) { const intent = window._mld_intents.find(i => i.id === intentId); if (!intent || card.dataset.busy) return; card.dataset.busy = '1'; const newPicked = !intent.is_picked; try { const u = JSON.parse(localStorage.getItem('mf_user') || '{}'); await post(`/announcements/intents/${intentId}/pick`, { is_picked: newPicked, checker_name: u.name || 'conferente' }); intent.is_picked = newPicked; _renderLotIntents(window._mld_intents); } catch(e) { toast('Erro ao registrar: ' + e.message, 'error'); delete card.dataset.busy; } }; window.printLotLabels = function() { const lot = window._mld_lot; if (!lot) { toast('Sem lote selecionado', 'error'); return; } const annId = lot.announcement_id; const round = lot.round || 1; window.open(`/api/orders/lot:${annId}::${round}/lot-labels`, '_blank'); }; window.retrocederLotStatus = async function(lotKey, prevStatus) { const meta = _LOT_STATUS_MAP[prevStatus]; if (!confirm(`Retroceder para "${meta?.label}"?`)) return; try { const u = JSON.parse(localStorage.getItem('mf_user') || '{}'); await patch(`/conferente/lots/${encodeURIComponent(lotKey)}/status`, { status: prevStatus, checker_name: u.name || 'conferente' }); const lot = (window.pedidosLots || []).find(l => l.lot_key === lotKey); if (lot) lot.status = prevStatus; toast('↩ Status revertido!'); loadPedidosTab(true); } catch(e) { toast('Erro ao retroceder status', 'error'); } }; window.advanceLotStatus = async function(lotKey, newStatus) { const btn = document.querySelector('#mld-status-bar button'); if (btn) { btn.disabled = true; btn.textContent = '...'; } try { const u = JSON.parse(localStorage.getItem('mf_user') || '{}'); await patch(`/conferente/lots/${encodeURIComponent(lotKey)}/status`, { status: newStatus, checker_name: u.name || 'conferente' }); const lot = (window.pedidosLots || []).find(l => l.lot_key === lotKey); if (lot) lot.status = newStatus; _renderLotStatusBar(lotKey, newStatus); toast('✅ Status atualizado!'); loadPedidosTab(true); } catch(e) { toast('Erro ao atualizar status', 'error'); if (btn) { btn.disabled = false; btn.textContent = (_LOT_STATUS_MAP[btn.dataset.next] || {}).nextLabel || '→'; } } }; // Dono da loja? — estorno é exclusivo dele function mfIsOwner() { return (JSON.parse(localStorage.getItem('mf_user') || '{}').role || '').toLowerCase() === 'owner'; } window.refundIntent = function(intentId, btn, fullAmount = 0, alreadyRefunded = 0) { const credBtn = document.getElementById('btnRefundCredit'); const pixBtn = document.getElementById('btnRefundPix'); if (!credBtn || !pixBtn) { toast('Modal de estorno não encontrado.', 'error'); return; } const fmt = (v) => 'R$ ' + (Number(v) || 0).toFixed(2).replace('.', ','); const paid = Math.round((Number(fullAmount) || 0) * 100) / 100; const prev = Math.round((Number(alreadyRefunded) || 0) * 100) / 100; const remaining = Math.round((paid - prev) * 100) / 100; // Preenche o resumo de valores document.getElementById('refundPaidLabel').textContent = fmt(paid); document.getElementById('refundAvailLabel').textContent = fmt(remaining); const alreadyRow = document.getElementById('refundAlreadyRow'); if (prev > 0) { alreadyRow.style.display = 'flex'; document.getElementById('refundAlreadyLabel').textContent = fmt(prev); } else { alreadyRow.style.display = 'none'; } // Reset estado total/parcial const partialWrap = document.getElementById('refundPartialWrap'); const partialInput = document.getElementById('refundPartialInput'); const partialErr = document.getElementById('refundPartialErr'); const lblTotal = document.getElementById('refundLblTotal'); const lblPartial = document.getElementById('refundLblPartial'); partialWrap.style.display = 'none'; partialErr.style.display = 'none'; partialInput.value = ''; partialInput.max = remaining.toFixed(2); document.querySelector('input[name="refundKind"][value="total"]').checked = true; const paintKind = () => { const isPartial = document.querySelector('input[name="refundKind"]:checked')?.value === 'partial'; partialWrap.style.display = isPartial ? 'block' : 'none'; lblPartial.style.borderColor = isPartial ? '#6366f1' : 'var(--border)'; lblPartial.style.background = isPartial ? 'rgba(99,102,241,.10)' : 'rgba(255,255,255,.04)'; lblTotal.style.borderColor = isPartial ? 'var(--border)' : '#6366f1'; lblTotal.style.background = isPartial ? 'rgba(255,255,255,.04)' : 'rgba(99,102,241,.10)'; if (isPartial) setTimeout(() => partialInput.focus(), 30); }; document.querySelectorAll('input[name="refundKind"]').forEach(r => { r.onchange = paintKind; }); paintKind(); // Lê e valida o valor escolhido; retorna null se inválido (mantém modal aberto). const resolveAmount = () => { const isPartial = document.querySelector('input[name="refundKind"]:checked')?.value === 'partial'; if (!isPartial) return remaining; const v = Math.round((parseFloat(partialInput.value) || 0) * 100) / 100; if (!v || v <= 0) { partialErr.textContent = 'Informe um valor maior que zero.'; partialErr.style.display = 'block'; return null; } if (v > remaining + 0.001) { partialErr.textContent = `Máximo disponível: ${fmt(remaining)}.`; partialErr.style.display = 'block'; return null; } partialErr.style.display = 'none'; return v; }; const doRefund = async (method) => { const amount = resolveAmount(); if (amount == null) return; // valor inválido — não fecha o modal closeModal('modalRefundChoice'); btn.disabled = true; btn.textContent = '⏳'; try { const data = await post(`/announcements/intents/${intentId}/refund`, { method, amount }); const amt = Number(data.amount).toFixed(2).replace('.',','); const archMsg = data.auto_archived ? ' Lote arquivado por falta de estoque.' : ''; const partMsg = data.is_partial ? ` (parcial — resta ${fmt(data.remaining_after)})` : ''; const msg = method === 'pix' ? `↩ Estorno de R$ ${amt}${partMsg} marcado como PIX manual para ${data.customer_name || 'cliente'}. Lembre de fazer a transferência pelo banco.${archMsg}` : `↩ Estorno de R$ ${amt}${partMsg} creditado para ${data.customer_name || 'cliente'}.${archMsg}`; toast(msg); if (window._mld_lotKey) { const d = await get(`/conferente/lots/${encodeURIComponent(window._mld_lotKey)}/intents`); window._mld_intents = d.intents || []; _renderLotIntents(window._mld_intents); } if (data.auto_archived) { closeLotModal(); loadPedidosTab(true); } } catch(e) { toast('Erro: ' + e.message, 'error'); btn.disabled = false; btn.textContent = '↩ Estornar'; } }; // Substitui handlers (clona pra remover listeners de aberturas anteriores) const newCred = credBtn.cloneNode(true); credBtn.replaceWith(newCred); const newPix = pixBtn.cloneNode(true); pixBtn.replaceWith(newPix); newCred.addEventListener('click', () => doRefund('credit')); newPix.addEventListener('click', () => doRefund('pix')); // Gerar crédito no portal é exclusivo do dono da loja (role 'owner'). // Conferente/vendedor só veem a opção de estorno via PIX manual. const _role = (JSON.parse(localStorage.getItem('mf_user') || '{}').role || '').toLowerCase(); if (_role !== 'owner') newCred.style.display = 'none'; openModal('modalRefundChoice'); }; function _renderLotStatusBar(lotKey, currentStatus) { const steps = ['received','ready_to_pick','ready','dispatched']; const idx = steps.indexOf(currentStatus === 'picking' ? 'ready_to_pick' : currentStatus); let html = `
    `; steps.forEach((s, i) => { const c = i <= idx ? (_LOT_STATUS_MAP[s]?.color || '#10b981') : 'var(--border)'; html += `
    `; }); html += `
    `; const meta = _LOT_STATUS_MAP[currentStatus] || { label: currentStatus, color: '#64748b' }; // Status anterior (pra botão Retroceder caso clique sem querer) const prevStatus = idx > 0 ? steps[idx - 1] : null; const prevMeta = prevStatus ? _LOT_STATUS_MAP[prevStatus] : null; html += `
    ● ${meta.label}
    `; if (prevStatus) { html += ``; } if (meta.next) { html += ``; } html += `
    `; document.getElementById('mld-status-bar').innerHTML = html; } function _renderLotIntents(intents) { if (!intents.length) { document.getElementById('mld-intents').innerHTML = `
    Nenhum pedido neste lote
    `; return; } const isPickingPhase = ['ready_to_pick','picking'].includes(window._mld_status); const active = intents.filter(i => i.status !== 'cancelled'); const pickedCount = active.filter(i => i.is_picked).length; const allPicked = active.length > 0 && pickedCount === active.length; // POS-aceitos (pedidos pós-fechamento) vão no FINAL da lista const sortedIntents = [...intents].sort((a, b) => { const aPos = a.post_close ? 1 : 0; const bPos = b.post_close ? 1 : 0; if (aPos !== bPos) return aPos - bPos; return new Date(a.created_at || 0) - new Date(b.created_at || 0); }); const sColors = { pending:'#64748b', billed:'#f59e0b', paid:'#10b981', cancelled:'#ef4444', picked:'#6366f1', refunded:'#8b5cf6' }; const sLabels = { pending:'Pendente', billed:'Cobrado', paid:'Pago Manual', cancelled:'Cancelado', picked:'Separado', refunded:'Gerado Créditos' }; let html = ''; if (isPickingPhase) { html += `
    Conferência de itens ${pickedCount}/${active.length} conferidos
    `; } html += `
    `; sortedIntents.forEach(i => { const name = i.customer_name || i.wa_sender_id || '?'; const isPaid = i.status === 'paid'; const color = sColors[i.status] || '#64748b'; const rawLabel = sLabels[i.status] || i.status; const label = isPaid && i.end_to_end_id ? 'Pago Sicredi' : rawLabel; const ltype = i.logistics_type === 'pickup' ? '🏪 Retirada' : i.logistics_type === 'delivery' ? '🚚 Entrega' : ''; const isPicked = !!i.is_picked; const canPick = isPickingPhase && i.status !== 'cancelled'; const isPos = !!i.post_close; const cardBg = canPick && isPicked ? 'rgba(249,115,22,.15)' : (isPos ? 'rgba(245,158,11,.06)' : 'var(--bg-card)'); const cardBorder = canPick && isPicked ? '1.5px solid rgba(249,115,22,.6)' : (isPos ? '1px solid rgba(245,158,11,.35)' : '1px solid var(--border)'); const cardCursor = canPick ? 'cursor:pointer;user-select:none' : ''; const clickAttr = canPick ? `onclick="toggleIntentPick('${i.id}',this)"` : ''; // Recalcula total a partir de unit_price quando total_amount está zerado const feeP = i.fee_percent != null ? parseFloat(i.fee_percent) : 13; const uPrice = parseFloat(i.unit_price) || 0; const qty = parseInt(i.qty) || 1; const displayAmt = uPrice > 0 && qty > 0 ? Math.round(uPrice * qty * (1 + feeP / 100) * 100) / 100 : (parseFloat(i.total_amount) || 0); // Valor já estornado (parcial ou total) e quanto ainda resta estornar const refundedAmt = Math.round((parseFloat(i.refund_amount) || 0) * 100) / 100; const remainingAmt = Math.round((displayAmt - refundedAmt) * 100) / 100; // Estorno é exclusivo do dono — conferente/vendedor não veem o botão. // Some quando não resta mais valor (estorno total já feito). const refundBtn = isPaid && remainingAmt > 0.009 && i.customer_id && mfIsOwner() ? `` : ''; // Selo visível do valor estornado (parcial → "parcial", total → valor cheio) const refundedBadge = refundedAmt > 0 ? `
    ↩ Estornado ${money(refundedAmt)}${(isPaid && remainingAmt > 0.009) ? ' (parcial)' : ''}
    ` : ''; const posTag = isPos ? `⏰ POS` : ''; html += `
    ${esc(name)}${posTag}
    ${ltype ? `
    ${ltype}
    ` : ''}
    ${money(displayAmt)}
    ${label}${i.qty > 1 ? ` ×${i.qty}` : ''}
    ${refundedBadge} ${refundBtn}
    `; }); html += `
    `; document.getElementById('mld-intents').innerHTML = html; // Destaca o botão de avanço quando todos conferidos if (isPickingPhase) { const advBtn = document.querySelector('#mld-status-bar button'); if (advBtn) { if (allPicked) { advBtn.style.animation = 'pulse-glow 1.4s ease-in-out infinite'; advBtn.style.background = '#10b981'; advBtn.textContent = `✅ Separação concluída (${pickedCount}/${active.length})`; } else { advBtn.style.animation = ''; advBtn.style.background = '#f97316'; advBtn.textContent = `→ Separação e Conferência (${pickedCount}/${active.length})`; } } } }