Script URL
LIGAR
?
Passos para ligar:
1. Abre a tua Google Sheet → Extensions → Apps Script
2. Cola o conteúdo do ficheiro bliss-cockpit-script.gs e guarda.
3. Clica Deploy → New deployment → Web app . Execute as: Me | Access: Anyone .
4. Autoriza, copia o URL e cola acima. Clica LIGAR .
✕
Pipeline ativo
–
orçamentos abertos
Tarefas pendentes
–
por concluir
Foco ativo
–
negócio em crescimento
Tarefas do dia
–
BLISS Cosmo Party
Cosmo Viagens Home Concept
Terapias SOUL Formação
ADD
Nova tarefa
Descrição
Negócio
BLISS Cosmo Party
Cosmo Viagens Home Concept
Terapias SOUL Formação
ADICIONAR
CANCELAR
Editar negócios
Nome Estado Label
+ ADICIONAR
GUARDAR
CANCELAR
Editar financeiro —
Descrição Negócio Valor € Tipo
+ LINHA
Metas — JSON ex: [{"label":"Meta CP","meta":4000}]
GUARDAR
CANCELAR
Pedidos recebidos
Nome / Email Data Plano Estado
↻ ATUALIZAR
FECHAR
// ─── CONSTANTS ───────────────────────────────────────────────
const LS_URL = 'bliss_script_url';
const LS_NOTA = 'bliss_nota';
let SCRIPT_URL = localStorage.getItem(LS_URL) || '';
// ─── STATE ───────────────────────────────────────────────────
let S = {
cfg: {
foco_ativo:'–', orcamentos_abertos:0,
negocios:[
{id:'soul', nome:'SOUL / Andreia Ramalheiro',status:'active', statusLabel:'Em crescimento'},
{id:'cp', nome:'Cosmopolitan Party', status:'hold', statusLabel:'Operacional'},
{id:'cv', nome:'Cosmopolitan Viagens', status:'quiet', statusLabel:'Latente'},
{id:'hc', nome:'Home Concept', status:'hold', statusLabel:'Operacional'},
{id:'at', nome:'Andreia Terapias', status:'hold', statusLabel:'Ativo (agenda)'},
{id:'form', nome:'Formação (Freelance)', status:'active', statusLabel:'Em prospeção'},
],
metas:[{label:'Meta CP',meta:4000},{label:'Meta SOUL',meta:1500}]
},
fin:{mes:getMes(), items:[], meses:[]},
tasks:[],
leads:[],
mesIdx:0,
};
// ─── INIT ────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('dateLbl').textContent = getDateStr();
document.getElementById('urlInput').value = SCRIPT_URL;
document.getElementById('notaArea').value = localStorage.getItem(LS_NOTA)||'';
document.querySelectorAll('.modal-overlay').forEach(m => {
m.addEventListener('click', e => { if(e.target===m) m.classList.remove('open'); });
});
if(SCRIPT_URL) { document.getElementById('urlBar').classList.add('hidden'); loadAll(); }
else { syncState('err','Sem ligação'); renderAll(); }
});
// ─── API ─────────────────────────────────────────────────────
async function get(params) {
if(!SCRIPT_URL) return null;
syncState('loading','A carregar…');
try {
const r = await fetch(`${SCRIPT_URL}?${new URLSearchParams(params)}`);
const d = await r.json();
if(d.error) throw new Error(d.error);
syncState('ok','Sincronizado');
return d;
} catch(e) { syncState('err','Erro: '+e.message); return null; }
}
async function post(body) {
if(!SCRIPT_URL) return null;
syncState('loading','A guardar…');
try {
const r = await fetch(SCRIPT_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d = await r.json();
if(d.error) throw new Error(d.error);
syncState('ok','Guardado');
return d;
} catch(e) { syncState('err','Erro: '+e.message); return null; }
}
// ─── LOAD ────────────────────────────────────────────────────
async function loadAll() {
const [cfg,fin,tasks,leadsRes] = await Promise.all([
get({action:'getConfig'}),
get({action:'getFinanceiro',mes:S.fin.mes}),
get({action:'getTarefas'}),
get({action:'getLeads'}),
]);
if(cfg) {
if(typeof cfg.negocios==='string') cfg.negocios=JSON.parse(cfg.negocios);
if(typeof cfg.metas ==='string') cfg.metas =JSON.parse(cfg.metas);
S.cfg={...S.cfg,...cfg};
}
if(fin) { S.fin=fin; S.mesIdx=fin.meses.indexOf(fin.mes); }
if(tasks) { S.tasks=tasks.tarefas||[]; }
if(leadsRes) { updateLeads(leadsRes.leads||[], false); }
renderAll();
// Poll for new leads every 2 minutes
setInterval(pollLeads, 120000);
}
async function loadFin(mes) {
const fin = await get({action:'getFinanceiro',mes});
if(fin){ S.fin=fin; S.mesIdx=fin.meses.indexOf(fin.mes); renderFin(); renderPulse(); }
}
// ─── SETUP URL ───────────────────────────────────────────────
function saveUrl() { SCRIPT_URL=document.getElementById('urlInput').value.trim(); localStorage.setItem(LS_URL,SCRIPT_URL); }
async function initSetup() {
saveUrl();
if(!SCRIPT_URL) return;
const ok = await get({action:'setup'});
if(ok) { document.getElementById('urlBar').classList.add('hidden'); document.getElementById('setupBanner').classList.add('hidden'); loadAll(); }
}
// ─── SYNC DOT ────────────────────────────────────────────────
function syncState(s,l) {
document.getElementById('syncDot').className='sync-dot '+s;
document.getElementById('syncLbl').textContent=l;
}
// ─── RENDER ──────────────────────────────────────────────────
function renderAll() { renderPulse(); renderNegs(); renderFin(); renderTasks(); }
function renderPulse() {
const pend = S.tasks.filter(t=>!t.feita).length;
const totPos = (S.fin.items||[]).filter(i=>i.tipo==='pos').reduce((a,b)=>a+(+b.valor||0),0);
const foco = Array.isArray(S.cfg.negocios) ? S.cfg.negocios.find(n=>n.status==='active') : null;
const focoNome = foco ? foco.nome.split('/')[0].trim() : (S.cfg.foco_ativo||'–');
document.getElementById('pOrc').textContent = S.cfg.orcamentos_abertos||0;
document.getElementById('pRec').textContent = eur(totPos);
document.getElementById('pRecMes').textContent = S.fin.mes||'este mês';
document.getElementById('pTar').textContent = pend;
document.getElementById('pFoc').textContent = focoNome;
}
function renderNegs() {
const negs = Array.isArray(S.cfg.negocios) ? S.cfg.negocios : [];
const foco = negs.find(n=>n.status==='active');
document.getElementById('focoNome').textContent = foco ? foco.nome : 'Nenhum negócio em crescimento ativo.';
document.getElementById('negList').innerHTML = negs.map(n=>`
${esc(n.nome)}
${esc(n.statusLabel)}
`).join('');
}
function renderFin() {
const f=S.fin;
document.getElementById('mesDisp').textContent=f.mes||'–';
let tot=0;
document.getElementById('finRows').innerHTML=(f.items||[]).map(i=>{
tot+=+i.valor||0;
const sign=i.valor>0?'+':'';
return `${esc(i.label)} ${sign}${eur(i.valor)}
`;
}).join('');
document.getElementById('finTot').textContent=(tot>=0?'+':'')+eur(tot);
// Progressos
const metas=Array.isArray(S.cfg.metas)?S.cfg.metas:[];
document.getElementById('progBox').innerHTML=metas.map(m=>{
const key=m.label.replace('Meta ','').toUpperCase();
const atual=(f.items||[]).filter(i=>i.tipo==='pos'&&((i.negocio||'').toUpperCase().includes(key)||(i.label||'').toUpperCase().includes(key))).reduce((a,b)=>a+(+b.valor||0),0);
const pct=Math.min(100,Math.round(atual/m.meta*100));
return `
${esc(m.label)} ${eur(atual)} / ${eur(m.meta)}
`;
}).join('');
}
function navMes(d) {
const m=S.fin.meses||[];
if(!m.length) return;
S.mesIdx=Math.max(0,Math.min(m.length-1,S.mesIdx+d));
loadFin(m[S.mesIdx]);
}
function renderTasks() {
const ts=S.tasks||[];
const pend=ts.filter(t=>!t.feita).length;
document.getElementById('taskCnt').textContent=`${pend} de ${ts.length} pendentes`;
document.getElementById('pTar').textContent=pend;
document.getElementById('taskList').innerHTML=ts.map(t=>`
${t.feita?'✓':''}
${esc(t.texto)}
${esc(t.tag||'')}
✕
`).join('');
}
// ─── TASK ACTIONS ────────────────────────────────────────────
async function toggleTask(id) {
const t=S.tasks.find(t=>String(t.id)===String(id)); if(!t) return;
t.feita=!t.feita; renderTasks();
await post({action:'setTarefa',id,feita:t.feita});
}
async function addTask() {
const txt=document.getElementById('newTxt').value.trim();
const tag=document.getElementById('newTag').value;
if(!txt) return;
document.getElementById('newTxt').value='';
const r=await post({action:'setTarefa',texto:txt,tag});
if(r){ S.tasks.unshift({id:r.id||Date.now(),texto:txt,tag,feita:false}); renderTasks(); }
}
async function addTaskModal() {
const txt=document.getElementById('mTxt').value.trim();
const tag=document.getElementById('mTag').value;
if(!txt) return;
document.getElementById('mTxt').value=''; closeModal('taskModal');
const r=await post({action:'setTarefa',texto:txt,tag});
if(r){ S.tasks.unshift({id:r.id||Date.now(),texto:txt,tag,feita:false}); renderTasks(); }
}
async function delTask(id) {
S.tasks=S.tasks.filter(t=>String(t.id)!==String(id)); renderTasks();
await post({action:'deleteTarefa',id});
}
// ─── NOTA ────────────────────────────────────────────────────
let notaT;
document.addEventListener('DOMContentLoaded',()=>{
document.getElementById('notaArea').addEventListener('input',()=>{ clearTimeout(notaT); notaT=setTimeout(saveNota,1800); });
});
function saveNota() {
const txt=document.getElementById('notaArea').value;
localStorage.setItem(LS_NOTA,txt);
const now=new Date();
const ts=`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
document.getElementById('notaTs').textContent='Auto-guardado '+ts;
document.getElementById('notaSaved').textContent='✓';
setTimeout(()=>document.getElementById('notaSaved').textContent='',2000);
post({action:'setConfig',config:{nota:txt,nota_ts:ts}});
}
// ─── MODAL NEGÓCIOS ──────────────────────────────────────────
function openNegModal() { populateNeRows(); openModal('negModal'); }
function populateNeRows() {
const negs=Array.isArray(S.cfg.negocios)?S.cfg.negocios:[];
document.getElementById('neRows').innerHTML=negs.map((n,i)=>neRowHtml(n,i)).join('');
}
function neRowHtml(n,i) {
return `
Crescimento ativo
Operacional
Latente
✕
`;
}
function addNeRow() {
const i=document.querySelectorAll('#neRows .ne-row').length;
const d=document.createElement('div'); d.className='ne-row'; d.id='ne'+i;
d.innerHTML=`
Crescimento ativo Operacional Latente
✕ `;
document.getElementById('neRows').appendChild(d);
}
async function saveNegocios() {
const rows=document.querySelectorAll('#neRows .ne-row');
const negocios=Array.from(rows).map((r,i)=>({
id: (r.querySelector('[data-f="nome"]')?.value||'neg'+i).toLowerCase().replace(/[\s\/]+/g,'_').slice(0,12),
nome: r.querySelector('[data-f="nome"]')?.value||'',
status: r.querySelector('[data-f="status"]')?.value||'hold',
statusLabel: r.querySelector('[data-f="statusLabel"]')?.value||'',
}));
S.cfg.negocios=negocios; closeModal('negModal'); renderNegs(); renderPulse();
await post({action:'setConfig',config:{negocios}});
}
// ─── MODAL FINANCEIRO ────────────────────────────────────────
function openFinEdit() {
const mes=S.fin.mes||getMes();
document.getElementById('finEditMes').textContent=mes;
document.getElementById('feRows').innerHTML=(S.fin.items||[]).map((it,i)=>feRowHtml(it,i)).join('');
document.getElementById('metasJson').value=JSON.stringify(Array.isArray(S.cfg.metas)?S.cfg.metas:[],null,2);
openModal('finModal');
}
function feRowHtml(it,i) {
return `
+ Entrada
− Custo
✕
`;
}
function addFeRow() {
const i=document.querySelectorAll('#feRows .fe-row').length;
const d=document.createElement('div'); d.className='fe-row'; d.id='fe'+i;
d.innerHTML=`
+ Entrada − Custo
✕ `;
document.getElementById('feRows').appendChild(d);
}
async function saveFinanceiro() {
const mes=S.fin.mes||getMes();
const rows=document.querySelectorAll('#feRows .fe-row');
const items=Array.from(rows).map(r=>({
label: r.querySelector('[data-f="label"]')?.value||'',
negocio: r.querySelector('[data-f="negocio"]')?.value||'',
valor: parseFloat(r.querySelector('[data-f="valor"]')?.value)||0,
tipo: r.querySelector('[data-f="tipo"]')?.value||'pos',
}));
try {
const mRaw=document.getElementById('metasJson').value.trim();
if(mRaw) S.cfg.metas=JSON.parse(mRaw);
} catch(e) { alert('JSON inválido: '+e.message); return; }
S.fin.items=items;
if(!S.fin.meses.includes(mes)) S.fin.meses.push(mes);
closeModal('finModal'); renderFin(); renderPulse();
await post({action:'setFinanceiro',mes,items});
await post({action:'setConfig',config:{metas:S.cfg.metas}});
}
// ─── MODAL CONFIG ────────────────────────────────────────────
function openModal(id) {
if(id==='cfgModal'){ document.getElementById('cfgOrc').value=S.cfg.orcamentos_abertos||0; document.getElementById('cfgFoc').value=S.cfg.foco_ativo||''; }
document.getElementById(id).classList.add('open');
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function saveConfig() {
const orc=parseInt(document.getElementById('cfgOrc').value)||0;
const foc=document.getElementById('cfgFoc').value.trim();
S.cfg.orcamentos_abertos=orc; S.cfg.foco_ativo=foc;
closeModal('cfgModal'); renderPulse();
await post({action:'setConfig',config:{orcamentos_abertos:orc,foco_ativo:foc}});
}
// ─── LEADS ───────────────────────────────────────────────────
async function loadLeads() {
const r = await get({action:'getLeads'});
if(r) updateLeads(r.leads||[], true);
}
async function pollLeads() {
const r = await get({action:'getLeads'});
if(r) updateLeads(r.leads||[], true);
}
function updateLeads(leads, notify) {
const prevNew = S.leads.filter(l=>l.estado==='Novo').length;
S.leads = leads;
const nowNew = leads.filter(l=>l.estado==='Novo').length;
// Notif badge
const badge = document.getElementById('leadsNotif');
if(nowNew > 0) {
badge.style.display = 'inline-flex';
badge.textContent = nowNew;
} else {
badge.style.display = 'none';
}
// Toast para novos leads (apenas quando polling, não no load inicial)
if(notify && nowNew > prevNew) {
const newest = leads.find(l=>l.estado==='Novo');
if(newest) showToast(`🎉 Novo pedido de ${newest.nome} — ${newest.plano}`);
}
// Atualiza count no modal se estiver aberto
const cnt = document.getElementById('leadsCount');
if(cnt) cnt.textContent = leads.length ? `(${leads.length})` : '';
renderLeads();
}
function renderLeads() {
const el = document.getElementById('leadsList');
if(!el) return;
if(!S.leads.length) {
el.innerHTML = 'Nenhum pedido ainda.
';
return;
}
el.innerHTML = S.leads.map(l => `
${esc(l.nome)}
${esc(l.email)}
${l.negocio ? `
${esc(l.negocio)}
` : ''}
${esc(String(l.data).slice(0,10))}
${esc(l.plano||'–')}
🔴 Novo
🟡 Contactado
🔵 Em proposta
🟢 Fechado
⚫ Cancelado
✉
`).join('');
}
async function updateLeadEstado(id, estado) {
const lead = S.leads.find(l=>String(l.id)===String(id));
if(lead) lead.estado = estado;
updateLeads(S.leads, false);
await post({action:'updateLead', id, estado});
}
function estadoCor(e) {
return {Novo:'var(--crimson)',Contactado:'var(--amber)',
'Em proposta':'#1a6abf',Fechado:'var(--green)',Cancelado:'var(--gray)'}[e]||'var(--text)';
}
function copyLeadEmail(email) {
navigator.clipboard.writeText(email).then(()=>showToast('✉️ Email copiado: '+email));
}
// ─── TOAST ───────────────────────────────────────────────────
function showToast(msg) {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = 'toast';
t.innerHTML = `🔔 ${esc(msg)} ✕ `;
c.appendChild(t);
setTimeout(()=>t.remove(), 6000);
}
// ─── UTILS ───────────────────────────────────────────────────
function eur(v){ return (parseFloat(v)||0).toLocaleString('pt-PT',{minimumFractionDigits:0,maximumFractionDigits:0})+' €'; }
function esc(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>'); }
function escA(s){ return String(s||'').replace(/"/g,'"'); }
function getMes(){ const n=new Date(); const m=['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']; return m[n.getMonth()]+' '+n.getFullYear(); }
function getDateStr(){ const n=new Date(); const d=['DOM','SEG','TER','QUA','QUI','SEX','SÁB']; const m=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ']; return `${d[n.getDay()]} ${n.getDate()} ${m[n.getMonth()]} ${n.getFullYear()}`; }