Carregando Gestão Predial…
`);d.close(); const imgs=[...d.images];Promise.race([Promise.all(imgs.map(i=>i.complete?1:new Promise(r=>{i.onload=i.onerror=r}))),new Promise(r=>setTimeout(r,6000))]).then(()=>{fr.contentWindow.focus();fr.contentWindow.print();setTimeout(()=>fr.remove(),3000)});toast('Abrindo impressão — escolha "Salvar como PDF" para gerar o arquivo.','info')} function printRecord(mk,r){const m=MODS[mk],fs=fieldsOf(mk);const ttl=m.title+(r.numero?' Nº '+r.numero:'');let h=`

${esc(ttl)}

${fs.filter(f=>!['fotos','foto'].includes(f.type)).map(f=>``).join('')}
${esc(f.l)}${esc(cellTxt(f,r[f.k])).replace(/\n/g,'
')}
Cadastrado em${fmtDT(r.created_at)}
Última atualização${fmtDT(r.updated_at)}
`; fs.filter(f=>f.type==='fotos').forEach(f=>{const a=J(r[f.k],[]);if(a.length)h+=`

${esc(f.l)}

${a.map(u=>``).join('')}
`});if(m.sign)h+=`
${m.sign.map(s=>`
${esc(s)}
`).join('')}
`;printDoc(ttl,h)} function printList(m,fs,rows,title){fs=fs.filter(f=>f.type!=='foto');printDoc(title||m.plural,`

${esc(title||'Relatório – '+m.plural)}

${rows.length} registro(s)

${fs.map(f=>``).join('')}${rows.map(r=>`${fs.map(f=>``).join('')}`).join('')}
${esc(f.l)}
${esc(f.k==='numero'?'#'+r.numero:cellTxt(f,r[f.k]))}
`)} function printTable(title,head,rows){printDoc(title,`

${esc(title)}

${rows.length} registro(s)

${head.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${esc(c)}
`)} /* ===== Início ===== */ const lemFor=(l,u=S.user)=>l.destino_tipo==='pessoal'?l.autor_id===u.id:l.destino_tipo==='todos'?true:l.destino_tipo==='equipe'?u.equipe===l.destino:l.destino_tipo==='papel'?u.papel===l.destino:l.destino_tipo==='usuario'?String(u.id)===String(l.destino):false; const lemDue=l=>new Date(`${l.data}T${l.hora||'00:00'}`)<=new Date(); VIEWS.inicio=async v=>{const p=cfg('predio'),h=new Date().getHours(),g=h<12?'Bom dia':h<18?'Boa tarde':'Boa noite',t=cfg('tema'); v.innerHTML=`
${new Date().toLocaleDateString('pt-BR',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}

${g}, ${esc(S.user.nome.split(' ')[0])}!

${esc(p.nome)} · ${ROLE[S.user.papel]}${S.user.equipe?' · Equipe '+esc(S.user.equipe):''}

${SKY}

Avisos e lembretes

Ver todos

Agenda de hoje

Acesso rápido

${menuItems().filter(m=>!m.hidden&&hasMod(m.k)&&m.k!=='inicio').map(m=>`${esc(m.l)}`).join('')}

Equipe

Abrir mensagens
`; const T=today(),get=(tb,pp)=>db.list(tb,pp).catch(()=>[]); const [mor,os,man,rs,ra,mat,lem,cie]=await Promise.all([hasMod('moradores')?get('Morador'):[],hasMod('os')?get('OrdemServico'):[],hasMod('manutencao')?get('Manutencao'):[],hasMod('reservas')?get('ReservaSalao',{data:T}):[],hasMod('reservas')?get('ReservaAcademia',{data:T}):[],hasMod('almoxarifado')?get('Material'):[],get('Lembrete'),get('Ciencia',{usuario_id:S.user.id})]); const st=[];if(hasMod('moradores'))st.push(['users-three','Moradores cadastrados',mor.length,'#/moradores','c1']);if(hasMod('os'))st.push(['clipboard-text','OS em aberto',os.filter(o=>!['Concluída','Cancelada'].includes(o.status)).length,'#/os','c2']);if(hasMod('manutencao'))st.push(['wrench','Manutenções pendentes',man.filter(x=>x.situacao!=='Concluída').length,'#/manutencao','c3']);if(hasMod('reservas'))st.push(['calendar-check','Reservas hoje',rs.concat(ra).filter(x=>x.status!=='Cancelada').length,'#/reservas','c4']);if(hasMod('almoxarifado'))st.push(['warning','Itens com estoque baixo',mat.filter(lowStock).length,'#/almoxarifado','c5']);st.push(['chat-circle-dots','Mensagens não lidas',S.unread||0,'#/mensagens','c6']); $('#st',v).innerHTML=st.map(([i,l,n,href,c])=>`
${n}${l}
`).join(''); const ack=new Map(cie.map(c=>[c.lembrete_id,c]));const mine=lem.filter(l=>lemFor(l)).sort((a,b)=>(b.data+b.hora).localeCompare(a.data+a.hora));const pend=mine.filter(l=>lemDue(l)&&!ack.has(l.id)),fut=mine.filter(l=>!lemDue(l)).reverse(),done=mine.filter(l=>ack.has(l.id)); const li=(l,s)=>`
${esc(l.titulo)}${fmtD(l.data)} ${esc(l.hora||'')} · ${s==='p'?'Aguardando sua confirmação':s==='f'?'Agendado':'Ciente em '+fmtDT(ack.get(l.id).quando)}
`; $('#dl',v).innerHTML=[...pend.map(l=>li(l,'p')),...fut.slice(0,4).map(l=>li(l,'f')),...done.slice(0,3).map(l=>li(l,'d'))].join('')||'

Nenhum aviso no momento.

'; const ag=[...rs.filter(x=>x.status!=='Cancelada').map(x=>[x.inicio,'confetti',`Salão de festas · ${x.morador}`,`${x.inicio}–${x.fim} · Unid. ${x.unidade} ${x.bloco||''} · ${x.convidados||0} convidados`]),...ra.filter(x=>x.status!=='Cancelada').map(x=>[x.horario,'barbell',`Academia · ${x.morador}`,`${x.horario} · ${x.pessoas||1} pessoa(s)`]),...os.filter(o=>o.prazo===T&&!['Concluída','Cancelada'].includes(o.status)).map(o=>['99','clipboard-text',`Prazo da OS #${o.numero}`,o.descricao])].sort((a,b)=>String(a[0]).localeCompare(String(b[0]))); $('#dr',v).innerHTML=ag.map(a=>`
${esc(a[2])}${esc(a[3])}
`).join('')||'

Nada agendado para hoje.

'; $('#don',v).innerHTML=S.users.filter(u=>u.ativo!==false).sort((a,b)=>(online(b)-online(a))||String(b.ultimo_acesso).localeCompare(String(a.ultimo_acesso))).slice(0,8).map(u=>`
${av(u,40,true)}
${esc(u.nome)}${ROLE[u.papel]}${u.equipe?' · '+esc(u.equipe):''} · ${ago(u.ultimo_acesso)}
`).join('')}; /* ===== Moradores ===== */ VIEWS.moradores=async v=>{v.innerHTML=tabs([['m','Moradores','users-three'],['v','Veículos','car'],['u','Resumo por unidade','buildings']])+'
';const tc=$('#tc',v); const show=async k=>{if(k==='m')await crud(tc,'moradores',{onLoad:r=>S.moradores=r});else if(k==='v'){await loadMor();await crud(tc,'veiculos')}else await unidades(tc)};wireTabs(v,show);await show('m')}; async function unidades(tc){const [ms,vs]=await Promise.all([db.list('Morador'),db.list('Veiculo')]);const g={},key=(b,u)=>`${String(b||'').trim().toUpperCase()}|${String(u||'').trim().toUpperCase()}`,get=(b,u)=>g[key(b,u)]||(g[key(b,u)]={b,u,m:[],v:[],q:0}); ms.forEach(m=>{const x=get(m.bloco,m.unidade);x.m.push(m);x.q=Math.max(x.q,Number(m.qtd_moradores)||0)});vs.forEach(c=>get(c.bloco,c.unidade).v.push(c));const arr=Object.values(g).sort((a,b)=>(String(a.b)+a.u).localeCompare(String(b.b)+b.u,'pt',{numeric:true})); tc.innerHTML=`
${arr.length} unidade(s) · ${ms.length} morador(es) · ${vs.length} veículo(s)
`; const grid=$('.ugrid',tc),q=$('input',tc);const card=x=>`
${esc(x.u||'—')}
Unidade ${esc(x.u||'—')}${x.b?'Bloco/Torre '+esc(x.b):'Sem bloco informado'}
${Math.max(x.q,x.m.length)} morador(es) ${x.v.length} veículo(s)
`; const draw=()=>{const s=norm(q.value);const f=arr.filter(x=>!s||norm(JSON.stringify(x)).includes(s));grid.innerHTML=f.length?f.map(card).join(''):'

Nenhuma unidade encontrada.

'};q.oninput=draw;draw(); $('#pu',tc).onclick=()=>printTable('Resumo de moradores e veículos por unidade',['Bloco','Unidade','Moradores','Qtde.','Veículos'],arr.map(x=>[x.b||'',x.u||'',x.m.map(m=>m.nome).join(', '),Math.max(x.q,x.m.length),x.v.map(c=>`${c.marca||''} ${c.modelo||''} ${c.cor||''} ${c.placa}`).join(' | ')]))} /* ===== Manutenção ===== */ VIEWS.manutencao=async v=>{let cat='Todas';const draw=async()=>{const cats=cfg('categorias');v.innerHTML=`
${['Todas',...cats].map(c=>``).join('')}${isMgr()?'':''}${isAdm()&&cat!=='Todas'?'':''}
`; $('.chips',v).onclick=async e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.c){cat=b.dataset.c;draw()}else if(b.dataset.new){const n=await promptBox('Nova categoria de manutenção','Nome da categoria / aba');if(n){const c=cfg('categorias');if(c.includes(n))return toast('Esta categoria já existe','err');c.push(n);await saveCfg('categorias',c);audit('manutencao','criou categoria','',n);cat=n;draw()}}else if(b.dataset.delc){if(await confirmBox(`Remover a aba "${cat}"? Os registros continuam salvos e aparecem em "Todas".`,'Remover')){await saveCfg('categorias',cfg('categorias').filter(x=>x!==cat));audit('manutencao','removeu categoria','',cat);cat='Todas';draw()}}}; await crud($('#mc',v),'manutencao',{filter:r=>cat==='Todas'||r.categoria===cat,preset:()=>cat!=='Todas'?{categoria:cat}:{},rowCls:r=>r.prioridade==='Urgente'&&r.situacao!=='Concluída'?'hot':''})};await draw()}; /* ===== Ordens de serviço ===== */ VIEWS.os=async v=>{let st='Todas';const draw=async()=>{v.innerHTML=`
${['Todas',...ST_OS].map(s=>``).join('')}
`;$('.chips',v).onclick=e=>{const b=e.target.closest('[data-s]');if(b){st=b.dataset.s;draw()}}; await crud($('#oc',v),'os',{filter:r=>st==='Todas'||r.status===st,rowCls:r=>r.prazo&&r.prazo{v.innerHTML=`
Cadastro de funcionários (com ou sem login). ${isMgr()?'O acesso ao sistema é criado em Usuários e Acessos; ':''}o registro de entrada e saída fica em Controle de Ponto.
`;await crud($('#fc',v),'funcionarios')}; /* ===== Controle de ponto ===== */ VIEWS.ponto=async v=>{const full=isMgr()||hasMod('funcionarios');const funcs=full?await db.list('Funcionario').catch(()=>[]):[]; const pessoas=[...S.users.filter(u=>u.ativo!==false).map(u=>({id:'u'+u.id,nome:u.nome})),...funcs.filter(f=>!S.users.some(u=>u.usuario&&u.usuario===f.usuario)).map(f=>({id:'f'+f.id,nome:f.nome}))].sort((a,b)=>a.nome.localeCompare(b.nome)); const opts=pessoas.map(p=>``).join(''); v.innerHTML=`
Registro de ponto de
${av(S.user,44)}${esc(S.user.nome)}
${new Date().toLocaleTimeString('pt-BR')}
${new Date().toLocaleDateString('pt-BR',{weekday:'long',day:'numeric',month:'long'})}
${full?`

Lançamento para funcionário

Use para registrar o ponto de quem não tem acesso ao sistema ou para ajustes. Fica registrado quem lançou.

`:`

Como funciona

Toque em Registrar entrada ao chegar e em Registrar saída ao sair. Data e horário são gravados automaticamente e ficam no seu histórico, visível também para a gerência.

`}
${full?``:''}
${tabs([['r','Movimentações','list-bullets'],['h','Horas por dia','timer']])}
`; S.viewTimer=setInterval(()=>{const c=$('#clk');if(c)c.textContent=new Date().toLocaleTimeString('pt-BR')},1000); let all=[],mode='r';const me='u'+S.user.id; const load=async()=>{all=full?await db.list('Ponto'):await db.list('Ponto',{pessoa_id:me});all.sort((a,b)=>(b.data+b.hora).localeCompare(a.data+a.hora));const l=all.find(x=>x.pessoa_id===me);$('#plast',v).innerHTML=l?`Último registro: ${esc(l.tipo)} em ${fmtD(l.data)} às ${esc(l.hora)}`:'Nenhum registro ainda.';draw()}; const rows=()=>{const p=$('#pp',v)?.value||'',d1=$('#pd1',v).value,d2=$('#pd2',v).value;return all.filter(x=>(!p||x.pessoa_id===p)&&(!d1||x.data>=d1)&&(!d2||x.data<=d2))}; const hours=list=>{const g={};[...list].sort((a,b)=>(a.data+a.hora).localeCompare(b.data+b.hora)).forEach(x=>{const k=x.pessoa_id+'|'+x.data;const o=g[k]||(g[k]={pessoa:x.pessoa,data:x.data,seq:[],min:0,open:null});o.seq.push((x.tipo==='Entrada'?'E ':'S ')+x.hora);const [h,m]=x.hora.split(':').map(Number),t=h*60+m;if(x.tipo==='Entrada')o.open=t;else if(o.open!=null){o.min+=t-o.open;o.open=null}});return Object.values(g).sort((a,b)=>b.data.localeCompare(a.data)||a.pessoa.localeCompare(b.pessoa))}; const hm=m=>`${Math.floor(m/60)}h${pad(m%60)}`; const draw=()=>{const list=rows(),box=$('#ptb',v);if(!list.length){box.innerHTML='

Nenhum registro no período.

';return} if(mode==='r')box.innerHTML=`${isMgr()?'':''}${list.map(x=>`${isMgr()?``:''}`).join('')}
DataHoraFuncionárioTipoRegistrado porObs.
${fmtD(x.data)}${esc(x.hora)}${esc(x.pessoa)}${badge(x.tipo)}${esc(x.registrado_por)}${esc(x.obs)}
`; else box.innerHTML=`${hours(list).map(o=>``).join('')}
DataFuncionárioMarcaçõesTotal trabalhado
${fmtD(o.data)}${esc(o.pessoa)}${esc(o.seq.join(' · '))}${o.open!=null?' em aberto':''}${hm(o.min)}
`}; wireTabs(v,k=>{mode=k;draw()});['#pp','#pd1','#pd2'].forEach(s=>{const e=$(s,v);if(e)e.onchange=draw}); $('#ptb',v).onclick=async e=>{const b=e.target.closest('[data-del]');if(!b)return;const x=all.find(y=>y.id==b.dataset.del);if(await confirmBox(`Excluir o registro de ${x.tipo} de ${x.pessoa} (${fmtD(x.data)} ${x.hora})?`,'Excluir')){await db.del('Ponto',x.id);audit('ponto','excluiu',x.id,`${x.tipo} de ${x.pessoa} ${fmtD(x.data)} ${x.hora}`);load()}}; const reg=async(pid,pessoa,tipo,data,hora,obs)=>{const r=await db.create('Ponto',{pessoa,pessoa_id:pid,tipo,data,hora,registrado_por:S.user.nome,obs:obs||''});audit('ponto',tipo.toLowerCase(),r.id,`${pessoa}: ${tipo} ${fmtD(data)} ${hora}${pid!==me?' (lançado por '+S.user.nome+')':''}`);toast(`${tipo} registrada às ${hora}`);load()}; $('[data-t]',v).forEach(b=>b.onclick=()=>busy(b,()=>reg(me,S.user.nome,b.dataset.t,today(),nowHM()))); const pf=$('#pf',v);if(pf)pf.onsubmit=e=>{e.preventDefault();const p=pessoas.find(x=>x.id===pf.pessoa.value);if(!p)return;busy($('[type=submit]',pf),()=>reg(p.id,p.nome,pf.tipo.value,pf.data.value,pf.hora.value,pf.obs.value))}; $('#pprint',v).onclick=()=>{const list=rows();mode==='r'?printTable('Controle de ponto – movimentações',['Data','Hora','Funcionário','Tipo','Registrado por','Obs.'],list.map(x=>[fmtD(x.data),x.hora,x.pessoa,x.tipo,x.registrado_por,x.obs])):printTable('Controle de ponto – horas por dia',['Data','Funcionário','Marcações','Total'],hours(list).map(o=>[fmtD(o.data),o.pessoa,o.seq.join(' · '),hm(o.min)]))}; await load()}; /* ===== Almoxarifado ===== */ async function movimentar(matId,tipo,qtd,obs){const m=(await db.list('Material')).find(x=>x.id==matId);if(!m)throw new Error('Material não encontrado.');const q=Number(qtd);if(!(q>0))throw new Error('Informe uma quantidade maior que zero.');const s=Math.round(((Number(m.quantidade)||0)+(tipo==='Entrada'?q:-q))*1000)/1000;if(s<0)throw new Error(`Saldo insuficiente. Disponível: ${m.quantidade} ${m.unidade_medida||''}`); await db.update('Material',m.id,{quantidade:s});await db.create('Movimento',{material_id:m.id,material:m.nome,tipo,quantidade:q,saldo:s,responsavel:S.user.nome,data_hora:nowISO(),obs:obs||''});audit('almoxarifado',tipo==='Entrada'?'entrada':'saída',m.id,`${q} ${m.unidade_medida||''} de ${m.nome} → saldo ${s}`); if(tipo==='Saída'&&lowStock({...m,quantidade:s}))notify([...admIds(),...S.users.filter(u=>u.papel==='GERENTE'&&u.ativo!==false).map(u=>u.id)],'Alerta de estoque baixo',`${m.nome}: saldo ${s} ${m.unidade_medida||''} (mínimo ${m.minimo})`,'#/almoxarifado');toast(`${tipo} registrada. Novo saldo: ${s} ${m.unidade_medida||''}`);return s} function movForm(mat,tipo,cb){const m=modal(`${mhead(`${tipo} de material`,tipo==='Entrada'?'arrow-circle-down':'arrow-circle-up')}
Material${esc(mat.nome)}Saldo atual: ${mat.quantidade} ${esc(mat.unidade_medida||'')}
`);const f=$('form',m);f.q.focus();const go=()=>busy($('#mvok',m),async()=>{await movimentar(mat.id,tipo,f.q.value,f.obs.value);m.remove();cb&&cb()});$('#mvok',m).onclick=go;f.onsubmit=e=>{e.preventDefault();go()}} VIEWS.almoxarifado=async v=>{v.innerHTML='
'+tabs([['m','Materiais','package'],['mv','Entrada / Saída','arrows-down-up'],['h','Histórico','clock-counter-clockwise'],['r','Relatório de estoque','chart-bar']])+'
';const ac=$('#ac',v); const alerts=mats=>{const low=mats.filter(lowStock);$('#alrt',v).innerHTML=low.length?`
Estoque baixo em ${low.length} item(ns): ${low.map(m=>`${esc(m.nome)} (${m.quantidade} ${esc(m.unidade_medida||'')}, mín. ${m.minimo})`).join(' · ')}
`:''}; const show=async k=>{ac.onclick=null; if(k==='m')return crud(ac,'materiais',{onLoad:alerts,rowCls:r=>lowStock(r)?'low':'',rowBtns:()=>``,onBtn:(a,r,reload)=>{if(a==='in'||a==='out')movForm(r,a==='in'?'Entrada':'Saída',reload)}}); const mats=await db.list('Material');mats.sort((a,b)=>a.nome.localeCompare(b.nome));alerts(mats); if(k==='mv'){ac.innerHTML=`

Registrar movimentação

O saldo é atualizado automaticamente e a movimentação fica no histórico com responsável, data e horário.

`;if(!mats.length)$('#mvf',ac).innerHTML='

Cadastre materiais primeiro na aba Materiais.

';const f=$('#mvf',ac);f.onsubmit=e=>{e.preventDefault();busy($('[type=submit]',f),async()=>{await movimentar(f.mat.value,f.tipo.value,f.q.value,f.obs.value);show('mv')})}} else if(k==='h'){const mv=(await db.list('Movimento')).sort((a,b)=>b.id-a.id);ac.innerHTML=`
`; const rows=()=>mv.filter(x=>(!$('#hm',ac).value||x.material_id==$('#hm',ac).value)&&(!$('#ht',ac).value||x.tipo===$('#ht',ac).value)&&(!$('#h1',ac).value||x.data_hora.slice(0,10)>=$('#h1',ac).value)&&(!$('#h2',ac).value||x.data_hora.slice(0,10)<=$('#h2',ac).value)); const draw=()=>{const l=rows();$('#hb',ac).innerHTML=l.length?`${l.map(x=>``).join('')}
Data / horaMaterialTipoQtde.SaldoResponsávelObs.
${fmtDT(x.data_hora)}${esc(x.material)}${badge(x.tipo)}${x.tipo==='Entrada'?'+':'−'}${x.quantidade}${x.saldo}${esc(x.responsavel)}${esc(x.obs)}
`:'

Nenhuma movimentação.

'}; $('select,input',ac).forEach(e=>e.onchange=draw);draw();$('#hp',ac).onclick=()=>printTable('Almoxarifado – histórico de movimentações',['Data/hora','Material','Tipo','Qtde.','Saldo','Responsável','Obs.'],rows().map(x=>[fmtDT(x.data_hora),x.material,x.tipo,x.quantidade,x.saldo,x.responsavel,x.obs]))} else{const mv=await db.list('Movimento');const d30=addDays(today(),-30);const agg=id=>{const l=mv.filter(x=>x.material_id==id&&x.data_hora.slice(0,10)>=d30);return [l.filter(x=>x.tipo==='Entrada').reduce((s,x)=>s+x.quantidade,0),l.filter(x=>x.tipo==='Saída').reduce((s,x)=>s+x.quantidade,0)]}; const data=mats.map(m=>{const [e,s]=agg(m.id);return [m.nome,m.unidade_medida||'',m.quantidade,m.minimo||0,lowStock(m)?'Baixo':'OK',e,s,m.local||'']}); ac.innerHTML=`

Posição de estoque

${data.map(r=>`${r.map((c,i)=>``).join('')}`).join('')}
MaterialUn.SaldoMínimoSituaçãoEntradas 30dSaídas 30dLocal
${i===4?badge(c==='Baixo'?'Urgente':'OK').replace(/Urgente|OK/,c):i===0?`${esc(c)}`:esc(c)}
`; $('#rp',ac).onclick=()=>printTable('Relatório de estoque – almoxarifado',['Material','Un.','Saldo','Mínimo','Situação','Entradas (30 dias)','Saídas (30 dias)','Local'],data)}}; wireTabs(v,show);await show('m')}; /* ===== Reservas e agendamentos ===== */ const acSlots=()=>{const a=cfg('academia'),o=[];for(let h=+a.inicio;h<+a.fim;h++)o.push(pad(h)+':00');return o}; MODS.salao={t:'ReservaSalao',mod:'reservas',title:'Reserva do salão de festas',plural:'Reservas do salão',icon:'confetti',fields:[{k:'morador',l:'Morador responsável',req:1,list:1,dl:'dl-mor'},{k:'unidade',l:'Unidade',req:1,list:1},{k:'bloco',l:'Bloco / Torre',list:1},{k:'data',l:'Data do evento',type:'date',req:1,def:today,list:1},{k:'inicio',l:'Início',type:'time',req:1,def:'18:00',list:1},{k:'fim',l:'Término',type:'time',req:1,def:'23:00',list:1},{k:'convidados',l:'Convidados previstos',type:'number',list:1},{k:'status',l:'Status',type:'select',options:['Solicitada','Confirmada','Cancelada','Concluída'],list:1,badge:1},{k:'obs',l:'Observações',type:'textarea'}], async before(d,old){if(d.fim<=d.inicio)throw new Error('O horário de término deve ser depois do início.');if(!old&&d.datax.id!==old?.id&&x.status!=='Cancelada'&&d.inicio[{k:'morador',l:'Morador',req:1,list:1,dl:'dl-mor'},{k:'unidade',l:'Unidade',req:1,list:1},{k:'bloco',l:'Bloco / Torre',list:1},{k:'data',l:'Data',type:'date',req:1,def:today,list:1},{k:'horario',l:'Horário',type:'select',options:acSlots(),list:1},{k:'pessoas',l:'Quantidade de pessoas',type:'number',def:1,list:1},{k:'status',l:'Status',type:'select',options:['Confirmada','Cancelada','Concluída'],list:1,badge:1},{k:'obs',l:'Observações',type:'textarea'}], async before(d,old){if(!old&&d.datax.horario===d.horario&&x.status!=='Cancelada'&&x.id!==old?.id).reduce((s,x)=>s+(Number(x.pessoas)||1),0);if(used+d.pessoas>cap)throw new Error(`Capacidade excedida: ${used} de ${cap} vagas já ocupadas às ${d.horario}. Restam ${Math.max(0,cap-used)}.`)}}; VIEWS.reservas=async v=>{await loadMor();v.innerHTML=tabs([['s','Salão de festas','confetti'],['a','Academia','barbell']])+'
';const rc=$('#rc',v);wireTabs(v,k=>k==='s'?salao(rc):academia(rc));await salao(rc)}; async function salao(el,ym){const d0=new Date();const y=ym?ym[0]:d0.getFullYear(),mo=ym?ym[1]:d0.getMonth();const rs=await db.list('ReservaSalao');const act=rs.filter(r=>r.status!=='Cancelada');const mk=`${y}-${pad(mo+1)}`,first=new Date(y,mo,1),dim=new Date(y,mo+1,0).getDate(),T=today(); let cells=['Dom','Seg','Ter','Qua','Qui','Sex','Sáb'].map(d=>`
${d}
`).join('');for(let i=0;i'; for(let d=1;d<=dim;d++){const ds=`${mk}-${pad(d)}`,day=act.filter(r=>r.data===ds).sort((a,b)=>a.inicio.localeCompare(b.inicio));cells+=``} const up=rs.filter(r=>r.data>=T&&r.status!=='Cancelada').sort((a,b)=>(a.data+a.inicio).localeCompare(b.data+b.inicio)); el.innerHTML=`

${first.toLocaleDateString('pt-BR',{month:'long',year:'numeric'})}

${hasMod('reservas')?'':''}
${cells}
SolicitadaConfirmadaConcluídaDias sem marcação estão livres

Próximas reservas

${up.length?up.slice(0,30).map(r=>`
${fmtD(r.data)} · ${esc(r.inicio)}–${esc(r.fim)}${esc(r.morador)} · Unid. ${esc(r.unidade)} ${esc(r.bloco||'')} · ${r.convidados||0} convidados
${badge(r.status)}
`).join(''):'

Nenhuma reserva futura.

'}
`; const re=()=>salao(el,[y,mo]);$('#cp',el).onclick=()=>salao(el,mo?[y,mo-1]:[y-1,11]);$('#cn',el).onclick=()=>salao(el,mo<11?[y,mo+1]:[y+1,0]); $('#nr',el)&&($('#nr',el).onclick=()=>openForm('salao',null,re)); $('.cal',el).onclick=e=>{const b=e.target.closest('[data-d]');if(!b)return;const ds=b.dataset.d,l=rs.filter(r=>r.data===ds).sort((a,b)=>a.inicio.localeCompare(b.inicio));const m=modal(`${mhead('Salão de festas · '+fmtD(ds),'calendar')}
${l.length?l.map(r=>`
${esc(r.inicio)}–${esc(r.fim)} · ${esc(r.morador)}Unid. ${esc(r.unidade)} ${esc(r.bloco||'')} · ${r.convidados||0} convidados${r.obs?' · '+esc(r.obs):''}
${badge(r.status)}
`).join(''):'

Data totalmente disponível.

'}
${hasMod('reservas')&&ds>=T?'':''}
`); m.onclick=ev=>{const it=ev.target.closest('[data-id]');if(it){m.remove();openForm('salao',rs.find(r=>r.id==it.dataset.id),re)}};const nb=$('#dnr',m);if(nb)nb.onclick=()=>{m.remove();openForm('salao',null,re,{data:ds})}}; $('#upl',el).onclick=e=>{const b=e.target.closest('[data-id]');if(b)openForm('salao',rs.find(r=>r.id==b.dataset.id),re)}; $('#prs',el).onclick=()=>printTable('Reservas do salão de festas',['Data','Início','Término','Morador','Unidade','Bloco','Convidados','Status','Obs.'],rs.filter(r=>r.data>=T).sort((a,b)=>(a.data+a.inicio).localeCompare(b.data+b.inicio)).map(r=>[fmtD(r.data),r.inicio,r.fim,r.morador,r.unidade,r.bloco,r.convidados,r.status,r.obs]))} async function academia(el,ds=today()){const ac=cfg('academia'),cap=+ac.cap,T=today();const all=await db.list('ReservaAcademia');const act=all.filter(r=>r.status!=='Cancelada');const day=all.filter(r=>r.data===ds).sort((a,b)=>a.horario.localeCompare(b.horario)); const used=(d,h)=>act.filter(r=>r.data===d&&r.horario===h).reduce((s,r)=>s+(Number(r.pessoas)||1),0);const slots=acSlots(); const strip=Array.from({length:14},(_,i)=>{const d=addDays(T,i),dt=new Date(d+'T12:00'),tot=act.filter(r=>r.data===d).reduce((s,r)=>s+(Number(r.pessoas)||1),0),pct=Math.round(tot/(cap*slots.length||1)*100);return ``}).join(''); el.innerHTML=`
Capacidade: ${cap} pessoas por horário${isMgr()?'':''}
${strip}
${slots.map(h=>{const u=used(ds,h),p=Math.min(100,Math.round(u/cap*100)),past=ds${h}
${u>=cap?'Lotado':`${cap-u} vaga(s) livre(s)`} · ${u}/${cap}`}).join('')}

Agendamentos de ${fmtD(ds)}

${day.length?day.map(r=>`
${esc(r.horario)} · ${esc(r.morador)}Unid. ${esc(r.unidade)} ${esc(r.bloco||'')} · ${r.pessoas||1} pessoa(s)
${badge(r.status)}${r.status!=='Cancelada'&&hasMod('reservas')?``:''}
`).join(''):'

Nenhum agendamento neste dia.

'}
`; const go=d=>academia(el,d),re=()=>go(ds);$('#dp',el).onclick=()=>go(addDays(ds,-1));$('#dn',el).onclick=()=>go(addDays(ds,1));$('#dd',el).onchange=e=>e.target.value&&go(e.target.value);$('.strip',el).onclick=e=>{const b=e.target.closest('[data-day]');if(b)go(b.dataset.day)}; $('.slots',el).onclick=e=>{const b=e.target.closest('[data-h]');if(!b||!hasMod('reservas'))return;if(used(ds,b.dataset.h)>=cap)return toast('Este horário está lotado. Escolha outro.','err');if(ds{const c=e.target.closest('[data-cancel]');if(c){e.stopPropagation();const r=all.find(x=>x.id==c.dataset.cancel);if(await confirmBox(`Cancelar o agendamento de ${r.morador} às ${r.horario}?`,'Cancelar agendamento')){await db.update('ReservaAcademia',r.id,{status:'Cancelada'});audit('academia','cancelou',r.id,`${r.morador} ${fmtD(r.data)} ${r.horario}`);toast('Agendamento cancelado');re()}return}const it=e.target.closest('[data-id]');if(it)openForm('academia',all.find(x=>x.id==it.dataset.id),re)}; $('#aprt',el).onclick=()=>printTable('Academia – agendamentos de '+fmtD(ds),['Horário','Morador','Unidade','Bloco','Pessoas','Status'],day.map(r=>[r.horario,r.morador,r.unidade,r.bloco,r.pessoas,r.status])); const cb=$('#acfg',el);if(cb)cb.onclick=()=>{const m=modal(`${mhead('Configurar academia','gear')}
`);$('#acok',m).onclick=()=>busy($('#acok',m),async()=>{const f=$('form',m),n={inicio:+f.i.value,fim:+f.f.value,cap:Math.max(1,+f.c.value)};if(n.fim<=n.inicio)throw new Error('O horário de fechamento deve ser após a abertura.');await saveCfg('academia',n);audit('academia','configurou','',JSON.stringify(n));m.remove();toast('Configuração salva');re()})}} /* ===== Documentos ===== */ const LIBS={mammoth:'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js',jszip:'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js',xlsx:'https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js'}; function sanitize(h){const d=new DOMParser().parseFromString('
'+h+'
','text/html');d.querySelectorAll('script,iframe,object,embed,link,meta,style,form,input,button').forEach(e=>e.remove());d.querySelectorAll('*').forEach(e=>[...e.attributes].forEach(a=>{if(/^on/i.test(a.name)||/javascript:/i.test(a.value))e.removeAttribute(a.name)}));return d.body.firstChild.innerHTML} const docHeader=()=>{const p=cfg('predio');return `
${p.logo?`Logotipo`:''}${esc(p.nome)}
Sistema de Gestão Predial
`}; const TPL=[['Ordem de Serviço','Ordem de serviço',`

ORDEM DE SERVIÇO Nº ____

Data de abertura: ___/___/____Prazo: ___/___/____
Solicitante:Setor / local:
Descrição do problema:


Responsável pela execução:Prioridade: ( ) Baixa ( ) Média ( ) Alta ( ) Urgente
Serviço executado / materiais utilizados:


Status: ( ) Aberta ( ) Em andamento ( ) ConcluídaData de conclusão: ___/___/____


________________________
Solicitante
________________________
Executor
`], ['Comunicado aos Moradores','Comunicado',`

COMUNICADO

Data: ___/___/____

Prezados moradores,

Informamos que ______________________________________________.

Contamos com a compreensão e colaboração de todos.


Atenciosamente,

Administração

`], ['Checklist de Ronda / Vistoria','Checklist',`

CHECKLIST DE VISTORIA

Data: ___/___/____   Responsável: __________________

ItemOKProblemaObservação
Elevadores
Iluminação das áreas comuns
Bombas e casa de máquinas
Portões e interfones
Extintores e hidrantes
Piscina
Limpeza geral
`], ['Controle de Retirada de Materiais','Controle',`

CONTROLE DE RETIRADA DE MATERIAIS

${''.repeat(10)}
DataMaterialQtde.DestinoResponsávelAssinatura
 
`]]; async function saveDoc(meta,html){const ch=[];for(let i=0;ips.filter(p=>p.versao!==ver).forEach(p=>db.del('DocParte',p.id).catch(()=>{}))).catch(()=>{});return {...meta,...data,id}} async function loadDoc(d){const ps=(await db.list('DocParte',{doc_id:d.id})).filter(p=>p.versao===d.versao).sort((a,b)=>a.ordem-b.ordem);return ps.map(p=>p.conteudo).join('')} async function seedModelos(){const ex=await db.list('Documento');if(ex.some(d=>d.oficial))return;for(const [t,c,h] of TPL)await saveDoc({titulo:t,categoria:c,oficial:true,origem:'Modelo padrão'},docHeader()+h)} function downloadDoc(t,html,fmt){const name=(t||'documento').replace(/[\\/:*?"<>|]/g,'_');if(fmt==='txt'){const d=document.createElement('div');d.innerHTML=html;return dlBlob(name+'.txt',new Blob([d.innerText],{type:'text/plain;charset=utf-8'}))}const full=`${esc(t)}${html}`;dlBlob(name+(fmt==='doc'?'.doc':'.html'),new Blob(['\ufeff',full],{type:fmt==='doc'?'application/msword':'text/html'}))} const csvToTable=t=>{const sep=(t.split('\n')[0].match(/;/g)||[]).length>(t.split('\n')[0].match(/,/g)||[]).length?';':',';return `${t.trim().split(/\r?\n/).map((l,i)=>`${l.split(sep).map(c=>`<${i?'td':'th'}>${esc(c.replace(/^"|"$/g,''))}`).join('')}`).join('')}
`}; async function importFile(file){const ext=file.name.split('.').pop().toLowerCase(),titulo=file.name.replace(/\.[^.]+$/,''); if(ext==='txt')return {titulo,html:'

'+esc(await file.text()).replace(/\r?\n\r?\n/g,'

').replace(/\r?\n/g,'
')+'

'}; if(ext==='html'||ext==='htm'){const d=new DOMParser().parseFromString(await file.text(),'text/html');return {titulo,html:sanitize(d.body.innerHTML)}} if(ext==='csv')return {titulo,html:csvToTable(await file.text())}; if(ext==='docx'){await loadScript(LIBS.mammoth);const r=await mammoth.convertToHtml({arrayBuffer:await file.arrayBuffer()},{convertImage:mammoth.images.imgElement(async img=>{try{const b=await img.read('base64');const bin=Uint8Array.from(atob(b),c=>c.charCodeAt(0));return {src:await upload(new Blob([bin],{type:img.contentType}),'img.'+(img.contentType.split('/')[1]||'png'))}}catch(e){return {src:''}}})});return {titulo,html:r.value}} if(ext==='odt'){await loadScript(LIBS.jszip);const z=await JSZip.loadAsync(file);const x=new DOMParser().parseFromString(await z.file('content.xml').async('string'),'application/xml');const body=x.getElementsByTagName('office:text')[0];let h='';const walk=n=>{for(const c of n.children){const t=c.tagName;if(t==='text:h')h+=`

${esc(c.textContent)}

`;else if(t==='text:p')h+=`

${esc(c.textContent)||'
'}

`;else if(t==='text:list')h+=`
    ${[...c.getElementsByTagName('text:list-item')].map(li=>`
  • ${esc(li.textContent)}
  • `).join('')}
`;else if(t==='table:table')h+=`${[...c.getElementsByTagName('table:table-row')].map(r=>`${[...r.getElementsByTagName('table:table-cell')].map(td=>``).join('')}`).join('')}
${esc(td.textContent)}
`;else if(t==='text:section')walk(c)}};if(body)walk(body);return {titulo,html:h||'

'}} if(['xlsx','xls','ods'].includes(ext)){await loadScript(LIBS.xlsx);const wb=XLSX.read(await file.arrayBuffer());const ws=wb.Sheets[wb.SheetNames[0]];const tb=XLSX.utils.sheet_to_html(ws).replace(/^[\s\S]*?[\s\S]*$/i,'
'); return new Promise(res=>{const m=modal(`${mhead('Planilha: somente visualização','table')}
Planilhas (${ext.toUpperCase()}) não são editáveis no editor. Você pode visualizar abaixo e baixar o arquivo original. Opcionalmente, converta os valores da primeira aba em uma tabela de documento (fórmulas e formatação não são preservadas).
${sanitize(tb)}
`,{cls:'wide',onClose:()=>res(null)});$('#xdl',m).onclick=()=>dlBlob(file.name,file);$('#xcv',m).onclick=()=>{m.remove();res({titulo,html:sanitize(tb)})}})} if(ext==='pdf'){const u=URL.createObjectURL(file);modal(`${mhead('PDF: somente visualização','file-pdf')}
Arquivos PDF permitem apenas visualização e download — não podem ser editados nem armazenados no servidor (o armazenamento aceita apenas imagens e documentos convertidos). Para editar, envie a versão DOCX, ODT ou TXT.
`,{cls:'wide'});return null} throw new Error('Formato não suportado. Use DOCX, ODT, TXT, HTML, CSV, XLSX ou PDF.')} const canEditDoc=d=>d.oficial?isAdm():(isMgr()||d.autor_id===S.user.id||hasMod('documentos')); const canDelDoc=d=>d.oficial?isAdm():(isMgr()||d.autor_id===S.user.id); VIEWS.documentos=async v=>{v.innerHTML=tabs([['d','Documentos','files'],['m','Modelos oficiais','seal-check']])+'
';const dc=$('#dc',v);wireTabs(v,k=>docList(dc,k==='m'));await docList(dc,false)}; async function docList(el,of){const docs=(await db.list('Documento')).filter(d=>!!d.oficial===of).sort((a,b)=>String(b.updated_at).localeCompare(String(a.updated_at))); el.innerHTML=`
${!of||isAdm()?``:''}
${of&&!isAdm()?'
Modelos oficiais são protegidos. Clique em Usar modelo para criar sua cópia editável — o original não é alterado.
':''}

Editáveis: DOCX, ODT, TXT, HTML e CSV (convertidos para o editor). PDF: apenas visualização/download. Planilhas (XLSX/XLS/ODS): visualização, com opção de converter os valores em tabela. Downloads em .doc (Word/LibreOffice), .html ou .txt.

`; const q=$('input',el);const draw=()=>{const s=norm(q.value),l=docs.filter(d=>!s||norm(d.titulo+' '+d.categoria+' '+d.autor).includes(s));$('#dg',el).innerHTML=l.length?`${l.map(d=>``).join('')}
DocumentoCategoriaAutorAtualizado
${esc(d.titulo)}${d.origem?`
${esc(d.origem)}`:''}
${esc(d.categoria)}${esc(d.autor)}${fmtDT(d.updated_at)}${d.atualizado_por?` por ${esc(d.atualizado_por)}`:''}${of?'':''}${canDelDoc(d)?'':''}
`:`

${docs.length?'Nada encontrado.':of?'Nenhum modelo oficial.':'Nenhum documento ainda. Crie um novo ou use um modelo oficial.'}

`};q.oninput=draw;draw(); const re=()=>docList(el,of); $('#dn',el)&&($('#dn',el).onclick=()=>openEditor({titulo:of?'Novo modelo':'Novo documento',oficial:of},docHeader()+'

',re)); $('#du',el)&&($('#du',el).onclick=async()=>{const f=await pickFile('.docx,.odt,.txt,.html,.htm,.csv,.xlsx,.xls,.ods,.pdf');if(!f)return;try{toast('Lendo arquivo…','info');const r=await importFile(f);if(r)openEditor({titulo:r.titulo,oficial:of,origem:'Importado de '+f.name},r.html,re)}catch(e){toast(e.message,'err')}}); $('#dg',el).onclick=async e=>{const b=e.target.closest('[data-a]');if(!b)return;const d=docs.find(x=>x.id==b.closest('tr').dataset.id),a=b.dataset.a;try{ if(a==='del'){if(await confirmBox(`Excluir "${d.titulo}"?`,'Excluir')){await db.del('Documento',d.id);(await db.list('DocParte',{doc_id:d.id})).forEach(p=>db.del('DocParte',p.id).catch(()=>{}));audit('documentos','excluiu',d.id,d.titulo);toast('Documento excluído');re()}return} const html=await loadDoc(d);if(a==='open')openEditor(d,html,re,!canEditDoc(d));else if(a==='use')openEditor({titulo:d.titulo+' – '+fmtD(today()),categoria:d.categoria,oficial:false,origem:'Baseado no modelo "'+d.titulo+'"'},html,re);else if(a==='doc')downloadDoc(d.titulo,html,'doc');else if(a==='prt')printDoc(d.titulo,`
${sanitize(html)}
`,true)}catch(err){toast(err.message,'err')}}} function openEditor(doc,html,onSaved,ro){let meta={...doc};const TB=[['bold','text-b','Negrito'],['italic','text-italic','Itálico'],['underline','text-underline','Sublinhado'],['strikeThrough','text-strikethrough','Tachado'],0,['formatBlock:H1','text-h-one','Título'],['formatBlock:H2','text-h-two','Subtítulo'],['formatBlock:P','paragraph','Parágrafo'],0,['insertUnorderedList','list-bullets','Lista'],['insertOrderedList','list-numbers','Lista numerada'],['justifyLeft','text-align-left','Esquerda'],['justifyCenter','text-align-center','Centro'],['justifyRight','text-align-right','Direita'],0,['table','table','Inserir tabela'],['insertHorizontalRule','minus','Linha'],['link','link','Link'],['image','image','Imagem'],['logo','buildings','Inserir logotipo'],0,['undo','arrow-counter-clockwise','Desfazer'],['redo','arrow-clockwise','Refazer'],['removeFormat','eraser','Limpar formatação']]; const m=modal(`
${ro?'':''}
${ro?'
Somente leitura. Para editar um modelo oficial, use "Usar modelo".
':`
${TB.map(t=>t?``:'').join('')}
`}
${sanitize(html)}
`,{cls:'editor',locked:true}); const pg=$('.edp',m);let dirty=false;pg.addEventListener('input',()=>dirty=true); $('#ex',m).onclick=async()=>{if(dirty&&!ro&&!await confirmBox('Fechar sem salvar as alterações?','Fechar sem salvar'))return;m.remove()}; const ins=h=>{pg.focus();document.execCommand('insertHTML',false,h);dirty=true}; if(!ro){$('.edtb',m).addEventListener('mousedown',e=>{if(e.target.closest('button'))e.preventDefault()});$('.edtb',m).onclick=async e=>{const b=e.target.closest('[data-c]');if(!b)return;const c=b.dataset.c; if(c==='logo'){const p=cfg('predio');if(!p.logo)return toast('Nenhum logotipo configurado (Painel do ADM → Aparência).','err');ins(`Logotipo`)} else if(c==='table'){const sel=window.getSelection(),rg=sel.rangeCount?sel.getRangeAt(0):null;const n=await promptBox('Inserir tabela','Linhas x colunas (ex.: 4x3)','4x3');if(!n)return;const [r,co]=n.toLowerCase().split('x').map(x=>Math.min(30,Math.max(1,parseInt(x)||2)));if(rg){sel.removeAllRanges();sel.addRange(rg)}ins(`${Array.from({length:r},()=>`${''.repeat(co)}`).join('')}
 


`)} else if(c==='link'){const sel=window.getSelection(),rg=sel.rangeCount?sel.getRangeAt(0):null;const u=await promptBox('Inserir link','Endereço (https://…)','https://');if(u&&/^https?:\/\//.test(u)){if(rg){sel.removeAllRanges();sel.addRange(rg)}pg.focus();document.execCommand('createLink',false,u)}} else if(c==='image'){const sel=window.getSelection(),rg=sel.rangeCount?sel.getRangeAt(0):null;try{const u=await pickImage({allowOriginal:true,outW:2000,title:'Ajustar imagem do documento'});if(u){if(rg){sel.removeAllRanges();sel.addRange(rg)}ins(``)}}catch(err){toast(err.message,'err')}} else if(c.startsWith('formatBlock:')){pg.focus();document.execCommand('formatBlock',false,c.split(':')[1])}else{pg.focus();document.execCommand(c,false,null)}dirty=true}; $('#efs',m).onchange=e=>{if(e.target.value){pg.focus();document.execCommand('fontSize',false,e.target.value);e.target.value='';dirty=true}};$('#ecol',m).onchange=e=>{pg.focus();document.execCommand('foreColor',false,e.target.value);dirty=true}; pg.addEventListener('paste',e=>{const items=[...(e.clipboardData?.items||[])].filter(i=>i.type.startsWith('image/'));if(items.length){e.preventDefault();const f=items[0].getAsFile();toast('Enviando imagem colada…','info');upload(f,'colada.png').then(u=>ins(``)).catch(err=>toast(err.message,'err'))}}); $('#esave',m).onclick=()=>busy($('#esave',m),async()=>{meta.titulo=$('#et',m).value.trim()||'Sem título';meta.categoria=$('#ec',m).value.trim();const h=sanitize(pg.innerHTML).replace(/src="data:[^"]{20000,}"/g,'src=""');const isNew=!meta.id;meta=await saveDoc(meta,h);dirty=false;audit('documentos',isNew?'criou':'editou',meta.id,meta.titulo+(meta.oficial?' (modelo oficial)':''));toast('Documento salvo');onSaved&&onSaved()})} $('#edl',m).onchange=e=>{if(e.target.value){downloadDoc($('#et',m).value,sanitize(pg.innerHTML),e.target.value);e.target.value=''}}; $('#eprt',m).onclick=()=>printDoc($('#et',m).value,`
${sanitize(pg.innerHTML)}
`,true)} document.head.insertAdjacentHTML('beforeend',``); /* ===== Mensagens ===== */ const readMap=()=>J(localStorage.getItem('gp_read_'+S.user.id),{}); const setRead=(c,t)=>{const r=readMap();if(!r[c]||t>r[c]){r[c]=t;localStorage.setItem('gp_read_'+S.user.id,JSON.stringify(r))}}; const dmKey=(a,b)=>'u:'+[+a,+b].sort((x,y)=>x-y).join('-'); function convOk(c,strict){if(c==='todos')return true;if(c.startsWith('e:'))return isMgr()||S.user.equipe===c.slice(2);if(c.startsWith('u:'))return c.slice(2).split('-').includes(String(S.user.id))||(!strict&&isAdm()&&S.verTodas);return false} function convInfo(c){if(c==='todos')return {nome:'Todos',sub:'Mensagem para toda a equipe',icon:'megaphone',members:S.users.filter(u=>u.ativo!==false)};if(c.startsWith('e:')){const e=c.slice(2);return {nome:'Equipe '+e,sub:'Mensagem para a equipe',icon:'users-three',members:S.users.filter(u=>u.equipe===e&&u.ativo!==false)}}const ids=c.slice(2).split('-').map(Number);const other=ids.find(i=>i!==S.user.id)??ids[0];const u=userById(other);const mine=ids.includes(S.user.id);return {nome:mine?(u?.nome||'Usuário'):ids.map(i=>userById(i)?.nome||'?').join(' ↔ '),sub:u?`${ROLE[u.papel]}${u.equipe?' · '+u.equipe:''} · ${ago(u.ultimo_acesso)}`:'',user:mine?u:null,members:ids.map(userById).filter(Boolean)}} async function refreshUnread(){S.msgs=await db.list('Mensagem',{sort:'created_at',order:'desc'},500);const rd=readMap();S.unread=S.msgs.filter(m=>m.de_id!==S.user.id&&convOk(m.conversa,true)&&String(m.created_at)>(rd[m.conversa]||'')).length;drawNav();if(S.onMsgs)S.onMsgs()} function bird(){const b=document.createElement('div');b.className='bird';b.setAttribute('aria-hidden','true');b.innerHTML=``;document.body.append(b);setTimeout(()=>b.remove(),2000)} async function sendMsg(conv,texto,reply){const info=convInfo(conv);await db.create('Mensagem',{conversa:conv,de_id:S.user.id,de_nome:S.user.nome,para_nome:info.nome,texto,resposta_a:reply?.id||0,resposta_txt:reply?(reply.de_nome+': '+reply.texto).slice(0,200):''});bird(); let ids=admIds();if(conv.startsWith('u:'))ids=ids.concat(conv.slice(2).split('-').map(Number));notify(ids,`Nova mensagem de ${S.user.nome}`,`Para: ${info.nome} — ${texto.slice(0,160)}`,'#/mensagens/'+conv)} VIEWS.mensagens=async v=>{let cur=decodeURIComponent(location.hash.split('/')[2]||'todos'),reply=null,q='';if(!convOk(cur))cur='todos'; v.innerHTML=`
${isAdm()?``:''}
`; if(window.innerWidth<=820&&!location.hash.split('/')[2])$('#chat',v).classList.remove('sel'); const lastOf=c=>S.msgs.find(m=>m.conversa===c);const rd=()=>readMap();const unr=c=>S.msgs.filter(m=>m.conversa===c&&m.de_id!==S.user.id&&String(m.created_at)>(rd()[c]||'')).length; const item=(c,av_,nome,sub)=>{const l=lastOf(c),n=unr(c);return `
${av_}
${esc(nome)}${l?esc((l.de_id===S.user.id?'Você: ':l.de_nome.split(' ')[0]+': ')+l.texto):esc(sub)}
${n?`${n}`:''}
`}; const drawList=()=>{const s=norm(q);const teams=isMgr()?cfg('equipes').map(e=>e.nome):(S.user.equipe?[S.user.equipe]:[]);let h='
Grupos
'+item('todos','','Todos','Toda a equipe'); teams.filter(t=>!s||norm(t).includes(s)).forEach(t=>h+=item('e:'+t,'','Equipe '+t,S.users.filter(u=>u.equipe===t).length+' membro(s)')); h+='
Pessoas
'+S.users.filter(u=>u.id!==S.user.id&&u.ativo!==false&&(!s||norm(u.nome+' '+u.equipe).includes(s))).sort((a,b)=>(online(b)-online(a))||a.nome.localeCompare(b.nome)).map(u=>item(dmKey(S.user.id,u.id),av(u,40,true),u.nome,`${ROLE[u.papel]} · ${ago(u.ultimo_acesso)}`)).join(''); if(isAdm()&&S.verTodas){const others=[...new Set(S.msgs.map(m=>m.conversa))].filter(c=>c.startsWith('u:')&&!c.slice(2).split('-').includes(String(S.user.id)));if(others.length)h+='
Conversas entre usuários (ADM)
'+others.map(c=>item(c,'',convInfo(c).nome,'')).join('')} $('#clb',v).innerHTML=h}; const drawConv=(scroll)=>{const info=convInfo(cur);const ms=S.msgs.filter(m=>m.conversa===cur).slice().reverse();const b=$('#cvb',v),atBottom=b.scrollHeight-b.scrollTop-b.clientHeight<80; $('#cvh',v).innerHTML=`${info.user?av(info.user,44,true):``}
${esc(info.nome)}${esc(info.sub)}
${info.members.slice(0,6).map(u=>av(u,30,true)).join('')}${info.members.length>6?`+${info.members.length-6}`:''}
`; b.innerHTML=ms.length?ms.map(m=>{const u=userById(m.de_id)||{nome:m.de_nome},me=m.de_id===S.user.id;return `
${av(u,32)}
${esc(m.de_nome)}
${m.resposta_txt?`
${esc(m.resposta_txt)}
`:''}${esc(m.texto).replace(/\n/g,'
')}
${fmtDT(m.created_at)}${isAdm()?``:''}
`}).join(''):`

Nenhuma mensagem ainda. Diga olá! 👋

`; if(scroll||atBottom)b.scrollTop=b.scrollHeight;if(ms.length)setRead(cur,String(ms[ms.length-1].created_at));$('#cbk',v).onclick=()=>{$('#chat',v).classList.remove('sel')}}; const refresh=(sc)=>{drawList();drawConv(sc)};S.onMsgs=()=>{if(!document.body.contains(v)||curKey()!=='mensagens'){S.onMsgs=null;return}const rd0=readMap();refresh(false);S.unread=S.msgs.filter(m=>m.de_id!==S.user.id&&convOk(m.conversa,true)&&String(m.created_at)>(rd0[m.conversa]||'')).length;drawNav()}; await refreshUnread();refresh(true); $('#clb',v).onclick=e=>{const c=e.target.closest('[data-c]');if(!c)return;cur=c.dataset.c;reply=null;$('#rbar',v).hidden=true;history.replaceState(null,'','#/mensagens/'+encodeURIComponent(cur));$('#chat',v).classList.add('sel');refresh(true);refreshUnread();$('#mt',v).focus()}; $('#cq',v).oninput=e=>{q=e.target.value;drawList()};const vt=$('#vt',v);if(vt)vt.onchange=()=>{S.verTodas=vt.checked;drawList()}; $('#cvb',v).onclick=async e=>{const r=e.target.closest('[data-r]'),d=e.target.closest('[data-d]');if(r){reply=S.msgs.find(m=>m.id==r.dataset.r);const rb=$('#rbar',v);rb.hidden=false;rb.innerHTML=`Respondendo a ${esc(reply.de_nome)}: ${esc(reply.texto)}`;$('#rx',v).onclick=()=>{reply=null;rb.hidden=true};$('#mt',v).focus()} if(d&&isAdm()){const m=S.msgs.find(x=>x.id==d.dataset.d);if(await confirmBox('Excluir esta mensagem para todos? A exclusão fica registrada na auditoria.','Excluir')){await db.del('Mensagem',m.id);audit('mensagens','excluiu',m.id,`${m.de_nome} → ${m.para_nome}: ${m.texto.slice(0,200)}`);toast('Mensagem excluída');await refreshUnread();refresh(false)}}}; const send=()=>{const t=$('#mt',v).value.trim();if(!t)return;busy($('#ms',v),async()=>{await sendMsg(cur,t,reply);$('#mt',v).value='';reply=null;$('#rbar',v).hidden=true;await refreshUnread();refresh(true)})}; $('#ms',v).onclick=send;$('#mt',v).onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}}; S.viewTimer=setInterval(()=>{if(curKey()==='mensagens')refreshUnread().catch(()=>{})},7000)}; /* ===== Contatar o ADM ===== */ function contatarADM(){const adms=S.users.filter(u=>u.papel==='ADM'&&u.ativo!==false);if(!adms.length)return toast('Nenhum administrador ativo.','err');const adm=adms[0],wa=cfg('whatsapp'); const m=modal(`${mhead('Contatar o ADM','headset')}
${av(adm,44,true)}
${esc(adm.nome)}Administrador · ${ago(adm.ultimo_acesso)}

O ADM recebe uma notificação no sistema imediatamente.

`); $('#caok',m).onclick=()=>busy($('#caok',m),async()=>{const f=$('form',m),a=f.assunto.value.trim(),t=f.texto.value.trim();if(!a||!t)throw new Error('Preencha assunto e mensagem.');const txt=`📩 ${a}\n${t}`;await sendMsg(dmKey(S.user.id,adm.id),txt);audit('mensagens','contatou ADM',adm.id,a); const num=digits(wa.numero);$('.mb',m).innerHTML=`

Mensagem enviada!

O ADM foi notificado no sistema e verá sua mensagem em Mensagens.

${num&&wa.botao!==false?` Avisar também pelo WhatsApp

Abre o WhatsApp com o texto pronto; você confirma o envio no aplicativo.

`:''}
`;$('.mf',m).innerHTML='';$('[data-close]',$('.mf',m)).onclick=()=>m.remove();if(curKey()==='mensagens')route()})} /* ===== Sugestões de melhoria ===== */ const ST_SUG=['Recebida','Em análise','Aprovada','Em andamento','Concluída'],REACT=['👍','❤️','💡','🎉','👀','🙏']; VIEWS.sugestoes=async v=>{const all=(await db.list('Sugestao')).sort((a,b)=>b.id-a.id);const list=isAdm()?all:all.filter(s=>s.autor_id===S.user.id);let fs=''; v.innerHTML=`
${isAdm()?'':`

Enviar nova sugestão

Você pode enviar quantas sugestões quiser. Só o ADM vê a área geral; você acompanha aqui as respostas.

Autor: ${esc(S.user.nome)} · Data: ${fmtD(today())}
`}

${isAdm()?'Todas as sugestões recebidas':'Minhas sugestões'}

${isAdm()?`
${['',...ST_SUG].map(s=>``).join('')}
`:''}
`; const draw=()=>{const l=list.filter(s=>!fs||s.status===fs);$('#sl',v).innerHTML=l.length?l.map(s=>{const h=J(s.historico,[]);return `
${esc(s.titulo)}
${esc(s.autor_nome)} · ${fmtDT(s.created_at)}
${badge(s.status)}${s.reacao?`${esc(s.reacao)}`:''}

${esc(s.descricao)}

${s.resposta?`
Resposta do ADM:
${esc(s.resposta).replace(/\n/g,'
')}
`:''} ${isAdm()?`
${REACT.map(r=>``).join('')}
`:''} ${h.length?`
Histórico (${h.length})
${h.map(x=>`
${esc(x.a)}${fmtDT(x.q)} · ${esc(x.p)}
`).join('')}
`:''}
`}).join(''):'

Nenhuma sugestão ainda.

'};draw(); const upd=async(s,patch,acao,msg)=>{const h=J(s.historico,[]);h.push({q:nowISO(),p:S.user.nome,a:acao});const p={...patch,historico:JSON.stringify(h.slice(-25))};await db.update('Sugestao',s.id,p);Object.assign(s,p);audit('sugestoes',acao,s.id,s.titulo);notify([s.autor_id],msg,`"${s.titulo}" — ${acao}`,'#/sugestoes');draw()}; const cf=$('.chips',v);if(cf)cf.onclick=e=>{const b=e.target.closest('[data-f]');if(b){fs=b.dataset.f;$('.chip',cf).forEach(c=>c.classList.toggle('on',c===b));draw()}}; $('#sl',v).onchange=e=>{if(!e.target.matches('[data-st]'))return;const s=list.find(x=>x.id==e.target.closest('[data-id]').dataset.id);upd(s,{status:e.target.value},'Status alterado para '+e.target.value,'Sua sugestão foi atualizada')}; $('#sl',v).onclick=async e=>{const c=e.target.closest('[data-id]');if(!c||!isAdm())return;const s=list.find(x=>x.id==c.dataset.id);const re=e.target.closest('[data-re]'); if(re)return upd(s,{reacao:s.reacao===re.dataset.re?'':re.dataset.re},'ADM reagiu '+re.dataset.re,'O ADM reagiu à sua sugestão'); if(e.target.closest('[data-resp]')){const m=modal(`${mhead('Responder sugestão','chat-centered-text')}

${esc(s.titulo)} — ${esc(s.autor_nome)}

`);$('#rok',m).onclick=()=>busy($('#rok',m),async()=>{const t=$('#rt',m).value.trim();if(!t)throw new Error('Escreva a resposta.');await upd(s,{resposta:t.slice(0,3000),status:s.status==='Recebida'?'Em análise':s.status},'ADM respondeu: '+t.slice(0,140),'O ADM respondeu sua sugestão');m.remove();toast('Resposta enviada ao autor')})} if(e.target.closest('[data-del]')&&await confirmBox('Excluir esta sugestão?','Excluir')){await db.del('Sugestao',s.id);audit('sugestoes','excluiu',s.id,s.titulo);VIEWS.sugestoes(v)}}; const f=$('#sf',v);if(f)f.onsubmit=e=>{e.preventDefault();busy($('[type=submit]',f),async()=>{const t=f.titulo.value.trim(),d=f.descricao.value.trim();if(!t||!d)throw new Error('Preencha título e descrição.');const s=await db.create('Sugestao',{autor_id:S.user.id,autor_nome:S.user.nome,titulo:t,descricao:d.slice(0,4000),status:'Recebida',reacao:'',resposta:'',historico:JSON.stringify([{q:nowISO(),p:S.user.nome,a:'Sugestão enviada'}])});audit('sugestoes','enviou',s.id,t);notify(admIds(),'Nova sugestão de melhoria',`${S.user.nome}: ${t}`,'#/sugestoes');bird();toast('Sugestão enviada ao ADM. Obrigado!');VIEWS.sugestoes(v)})}}; /* ===== Lembretes e avisos ===== */ async function checkLembretes(){if(S.lemOpen||!S.user)return;try{const [ls,cs]=await Promise.all([db.list('Lembrete'),db.list('Ciencia',{usuario_id:S.user.id})]);const ack=new Set(cs.map(c=>c.lembrete_id));const due=ls.filter(l=>lemFor(l)&&lemDue(l)&&!ack.has(l.id)).sort((a,b)=>(a.data+a.hora).localeCompare(b.data+b.hora));if(!due.length||S.lemOpen)return;const l=due[0];S.lemOpen=true; const m=modal(`
${l.destino_tipo==='pessoal'?'Lembrete pessoal':'Aviso'} · ${fmtD(l.data)} ${esc(l.hora||'')}${due.length>1?` · 1 de ${due.length}`:''}

${esc(l.titulo)}

${esc(l.descricao||'')}

Enviado por ${esc(l.autor_nome)}
`,{locked:true}); $('#lok',m).onclick=()=>busy($('#lok',m),async()=>{await db.create('Ciencia',{lembrete_id:l.id,usuario_id:S.user.id,usuario_nome:S.user.nome,quando:nowISO()});m.remove();S.lemOpen=false;if(curKey()==='inicio'||curKey()==='lembretes')route();checkLembretes()})}catch(e){S.lemOpen=false}} VIEWS.lembretes=async v=>{const [ls,cs]=await Promise.all([db.list('Lembrete'),db.list('Ciencia')]);ls.sort((a,b)=>(b.data+b.hora).localeCompare(a.data+a.hora));const mineAck=new Map(cs.filter(c=>c.usuario_id===S.user.id).map(c=>[c.lembrete_id,c])); const dest=isMgr()?`${cfg('equipes').map(e=>``).join('')}${S.users.filter(u=>u.ativo!==false&&u.id!==S.user.id).map(u=>``).join('')}`:''; const destLbl=l=>l.destino_tipo==='pessoal'?'Pessoal':l.destino_tipo==='todos'?'Todos':l.destino_tipo==='equipe'?'Equipe '+l.destino:l.destino_tipo==='papel'?(l.destino==='GERENTE'?'Gerentes':'Funcionários'):(userById(l.destino)?.nome||'Usuário'); const targets=l=>S.users.filter(u=>u.ativo!==false&&lemFor(l,u)); const forMe=ls.filter(l=>lemFor(l)),made=ls.filter(l=>l.autor_id===S.user.id&&l.destino_tipo!=='pessoal'),others=isAdm()?ls.filter(l=>l.autor_id!==S.user.id&&!lemFor(l)&&l.destino_tipo!=='pessoal'):[]; const row=(l,mgmt)=>{const ack=cs.filter(c=>c.lembrete_id===l.id),tg=targets(l),a=mineAck.get(l.id),due=lemDue(l);return `
${esc(l.titulo)}${fmtD(l.data)} ${esc(l.hora||'')} · ${esc(destLbl(l))} · por ${esc(l.autor_nome)}${lemFor(l)?(a?` · ✅ ciente em ${fmtDT(a.quando)}`:due?' · aguardando sua confirmação':' · agendado'):''}${l.descricao?`${esc(l.descricao)}`:''}
${mgmt&&l.destino_tipo!=='pessoal'?``:''}${l.autor_id===S.user.id||isAdm()?'':''}
`}; v.innerHTML=`

${isMgr()?'Novo aviso ou lembrete':'Novo lembrete pessoal'}

Na data e hora definidas, o aviso aparece em destaque na tela de cada destinatário e só some após clicar em OK, CIENTE.

Para mim

${forMe.map(l=>row(l,l.autor_id===S.user.id||isAdm())).join('')||'

Nenhum lembrete.

'}
${made.length?`

Avisos que criei

${made.filter(l=>!lemFor(l)).map(l=>row(l,1)).join('')||'

Os avisos criados por você que também te incluem aparecem acima.

'}
`:''}${others.length?`

Outros avisos (visão do ADM)

${others.map(l=>row(l,1)).join('')}
`:''}
`; v.onclick=async e=>{const it=e.target.closest('[data-id]');if(!it)return;const l=ls.find(x=>x.id==it.dataset.id); if(e.target.closest('[data-ck]')){const ack=cs.filter(c=>c.lembrete_id===l.id),tg=targets(l);modal(`${mhead('Confirmações de leitura','checks')}

${esc(l.titulo)} · ${fmtD(l.data)} ${esc(l.hora)}

${tg.map(u=>{const a=ack.find(c=>c.usuario_id===u.id);return `
${av(u,34)}
${esc(u.nome)}${a?'Ciente em '+fmtDT(a.quando):'Ainda não confirmou'}
${a?badge('Confirmada'):badge('Pendente')}
`}).join('')}
`,{cls:'wide'})} if(e.target.closest('[data-del]')&&await confirmBox(`Excluir "${l.titulo}"?`,'Excluir')){await db.del('Lembrete',l.id);audit('lembretes','excluiu',l.id,l.titulo);VIEWS.lembretes(v)}}; const f=$('#lf2',v);f.onsubmit=e=>{e.preventDefault();busy($('[type=submit]',f),async()=>{const [tp,...rest]=f.dest.value.split(':');const l=await db.create('Lembrete',{titulo:f.titulo.value.trim(),descricao:f.descricao.value.trim(),data:f.data.value,hora:f.hora.value,destino_tipo:tp,destino:rest.join(':'),autor_id:S.user.id,autor_nome:S.user.nome});audit('lembretes','criou',l.id,`${l.titulo} (${destLbl(l)}) para ${fmtD(l.data)} ${l.hora}`);toast('Lembrete agendado');VIEWS.lembretes(v);checkLembretes()})}}; /* ===== Usuários e acessos ===== */ const canAdminUser=u=>isAdm(); function showTemp(u,pw,title){const m=modal(`${mhead(title,'key')}
Anote ou copie agora: por segurança esta senha não será exibida novamente. No primeiro acesso o usuário deverá criar uma senha pessoal.
Usuário${esc(u.usuario)}
${u.email?`
ou e-mail${esc(u.email)}
`:''}${u.telefone?`
ou telefone${esc(u.telefone)}
`:''}
Senha provisória${esc(pw)}
`,{locked:true});$('#cpy',m).onclick=()=>{navigator.clipboard.writeText(`${cfg('predio').nome} – Sistema de Gestão Predial\nEndereço: ${location.origin}\nUsuário: ${u.usuario}\nSenha provisória: ${pw}`).then(()=>toast('Dados copiados'))}} function userForm(u,cb){const isNew=!u,adm=isAdm();if(!isNew&&!canAdminUser(u))return toast('Somente o ADM pode editar acessos existentes.','err');const roles=adm?['FUNCIONARIO','GERENTE','ADM']:['FUNCIONARIO'];u=u||{papel:'FUNCIONARIO',ativo:true}; const m=modal(`${mhead(isNew?'Novo acesso':'Editar acesso de '+u.nome,isNew?'user-plus':'user-gear')}
${isNew?'
Uma senha provisória segura será gerada automaticamente e exibida uma única vez. O funcionário não precisa ter e-mail.
':``}
`,{cls:'wide'}); const f=$('form',m);if(isNew)f.nome.oninput=()=>{if(!f.usuario.dataset.t)f.usuario.value=suggestUser(f.nome.value)};f.usuario.oninput=()=>f.usuario.dataset.t=1; $('#uok',m).onclick=()=>busy($('#uok',m),async()=>{S.users=await db.list('Usuario');const o={nome:f.nome.value.trim(),usuario:f.usuario.value.trim().toLowerCase().replace(/\s+/g,'.'),email:f.email.value.trim().toLowerCase(),telefone:f.telefone.value.trim(),papel:f.papel.value,equipe:f.equipe.value,cargo:f.cargo.value.trim()};if(!o.nome||!o.usuario)throw new Error('Preencha nome e usuário.');if(!/^[a-z0-9._-]{3,}$/.test(o.usuario))throw new Error('Usuário: mínimo 3 caracteres (letras, números, ponto, hífen).');uniqCheck(o,u.id);if(!adm&&o.papel!=='FUNCIONARIO')throw new Error('Gerentes só podem criar acessos de funcionários.'); if(isNew){const pw=tempPw();const nu=await db.create('Usuario',{...o,...await pwFields(pw),troca_senha:true,ativo:true,foto:'',criado_por:S.user.nome,criado_por_id:S.user.id,criado_por_papel:S.user.papel,ultimo_acesso:''});audit('usuarios','criou',nu.id,`${o.nome} (@${o.usuario}, ${ROLE[o.papel]}) criado por ${S.user.nome} [${ROLE[S.user.papel]}]`);if(S.user.papel==='GERENTE')notify(admIds(),'Novo acesso criado por gerente',`${S.user.nome} criou o acesso de ${o.nome} (@${o.usuario})`,'#/usuarios');m.remove();S.users=await db.list('Usuario');showTemp(nu,pw,'Acesso criado');cb&&cb()} else{o.ativo=f.ativo.checked;if(u.id===S.user.id&&(!o.ativo||o.papel!=='ADM'))throw new Error('Você não pode desativar nem rebaixar o seu próprio acesso de ADM.');const ch=Object.keys(o).filter(k=>String(o[k]??'')!==String(u[k]??'')).map(k=>`${k}: "${u[k]??''}" → "${o[k]}"`);if(!ch.length){m.remove();return}await db.update('Usuario',u.id,o);audit('usuarios',o.ativo!==(u.ativo!==false)?(o.ativo?'reativou':'desativou'):'editou',u.id,`${u.nome}: ${ch.join(' | ')}`);m.remove();toast('Acesso atualizado');cb&&cb()}})} async function resetPw(u,cb){if(!isAdm())return toast('Somente o ADM pode redefinir senhas.','err');if(!await confirmBox(`Gerar uma nova senha provisória para ${u.nome}? A senha atual deixará de funcionar e a sessão dele será encerrada.`,'Redefinir senha',false))return;const pw=tempPw();await db.update('Usuario',u.id,{...await pwFields(pw),troca_senha:true});audit('usuarios','redefiniu senha',u.id,`Senha de ${u.nome} redefinida por ${S.user.nome}`);if(u.id===S.user.id){toast('Sua senha foi redefinida. Entre com a senha provisória.','info');setTimeout(()=>logout(true),400);return}S.users=await db.list('Usuario');showTemp(u,pw,'Senha redefinida');cb&&cb()} VIEWS.usuarios=async v=>{S.users=await db.list('Usuario');let flt='';const adm=isAdm(); const base=()=>adm?S.users:S.users.filter(u=>u.papel==='FUNCIONARIO'||u.criado_por_id===S.user.id); v.innerHTML=`${adm?'':'
Como gerente, você pode criar acessos de funcionários sem aprovação. Alterações, redefinição de senha e desativação desses acessos são feitas somente pelo ADM.
'}${tabs([['l','Usuários','users'],['h','Histórico de acessos','clock-counter-clockwise']])}
`;const uc=$('#uc',v); const list=()=>{uc.innerHTML=`
${adm?``:''}
`; const rows=()=>{const s=norm($('#uq',uc).value);return base().filter(u=>(!flt||(flt==='ger'?u.criado_por_papel==='GERENTE':flt==='ina'?u.ativo===false:u.papel===flt))&&(!s||norm(`${u.nome} ${u.usuario} ${u.email} ${u.telefone} ${u.equipe}`).includes(s)||digits(u.telefone).includes(digits(s)||'#'))).sort((a,b)=>a.nome.localeCompare(b.nome))}; const draw=()=>{const l=rows();$('#ub',uc).innerHTML=`${l.map(u=>``).join('')}
UsuárioLoginContatoPerfil / equipeSituaçãoCriado porÚltimo acesso
${av(u,36,true)}${esc(u.nome)}
@${esc(u.usuario)}${esc(u.email||'')}${u.email&&u.telefone?'
':''}${esc(u.telefone||'')}
${badge(ROLE[u.papel]).replace(/b-[a-z]+/,'b-'+slug(u.papel))} ${esc(u.equipe||'')}${u.ativo===false?badge('Inativo'):badge('Ativo')}${u.troca_senha?' senha provisória':''}${esc(u.criado_por||'')}${u.criado_por_papel?` (${ROLE[u.criado_por_papel]||''})`:''}
${fmtD(u.created_at)}
${ago(u.ultimo_acesso)}${adm?`${u.id!==S.user.id?``:''}`:'somente ADM edita'}
${l.length} usuário(s)
`}; draw();$('#uq',uc).oninput=draw;const sf=$('#uf',uc);if(sf)sf.onchange=()=>{flt=sf.value;draw()};$('#un',uc).onclick=()=>userForm(null,()=>VIEWS.usuarios(v)); $('#up',uc).onclick=()=>printTable('Usuários e acessos',['Nome','Usuário','E-mail','Telefone','Perfil','Equipe','Situação','Criado por','Último acesso'],rows().map(u=>[u.nome,u.usuario,u.email,u.telefone,ROLE[u.papel],u.equipe,u.ativo===false?'Inativo':'Ativo',u.criado_por,ago(u.ultimo_acesso)])); $('#ub',uc).onclick=async e=>{const b=e.target.closest('[data-a]');if(!b)return;const u=userById(b.closest('tr').dataset.id);const re=()=>VIEWS.usuarios(v);if(b.dataset.a==='ed')userForm(u,re);else if(b.dataset.a==='pw')resetPw(u,re);else if(b.dataset.a==='tg'){const at=u.ativo===false;if(await confirmBox(`${at?'Reativar':'Desativar'} o acesso de ${u.nome}?${at?'':' Ele não conseguirá mais entrar e será desconectado.'}`,at?'Reativar':'Desativar',!at)){await db.update('Usuario',u.id,{ativo:at});audit('usuarios',at?'reativou':'desativou',u.id,u.nome);toast('Acesso '+(at?'reativado':'desativado'));S.users=await db.list('Usuario');draw()}}}}; const hist=async()=>{uc.innerHTML='
';let hs=(await db.list('Historico',{modulo:'usuarios'})).sort((a,b)=>b.id-a.id);if(!adm)hs=hs.filter(h=>h.usuario_id===S.user.id);uc.innerHTML=`
${hs.length?`
${hs.slice(0,300).map(h=>`
${esc(h.acao)} por ${esc(h.usuario)}${fmtDT(h.quando)}

${esc(h.detalhes)}

`).join('')}
`:'

Sem movimentações.

'}
`}; wireTabs(v,k=>k==='l'?list():hist());list()}; /* ===== Meu perfil ===== */ VIEWS.perfil=async v=>{const u=S.user;v.innerHTML=`
${av(u,140)}

${esc(u.nome)}

@${esc(u.usuario)} · ${ROLE[u.papel]}${u.equipe?' · Equipe '+esc(u.equipe):''}${u.cargo?' · '+esc(u.cargo):''}

Você pode arrastar, ampliar e enquadrar a foto antes de salvar. Ela aparece nas mensagens e na lista de usuários online.

${u.foto?'':''}

Dados de contato e login

Você pode entrar usando o nome de usuário, o e-mail ou o telefone cadastrados.

Alterar senha

`;wireEyes(v); const upd=async(p,acao)=>{await db.update('Usuario',u.id,p);Object.assign(S.user,p);audit('usuarios',acao,u.id,u.nome);if(p.senha_hash){const ss=sess();ss.sig=p.senha_hash.slice(0,16);localStorage.setItem('gp_sess',JSON.stringify(ss))}}; $('#pf',v).onclick=async()=>{try{const url=await pickImage({aspect:1,outW:800,round:true,title:'Enquadrar foto de perfil'});if(url){await upd({foto:url},'alterou foto de perfil');toast('Foto atualizada');startApp()}}catch(e){toast(e.message,'err')}}; const rm=$('#prm',v);if(rm)rm.onclick=async()=>{await upd({foto:''},'removeu foto de perfil');startApp()}; const cf=$('#cf',v);cf.onsubmit=e=>{e.preventDefault();busy($('[type=submit]',cf),async()=>{S.users=await db.list('Usuario');const o={nome:cf.nome.value.trim(),email:cf.email.value.trim().toLowerCase(),telefone:cf.telefone.value.trim()};if(!o.nome)throw new Error('Informe o nome.');uniqCheck(o,u.id);await upd(o,'alterou dados de contato');toast('Dados atualizados');startApp()})}; const sf=$('#sf2',v);sf.onsubmit=e=>{e.preventDefault();busy($('[type=submit]',sf),async()=>{if(await hashPw(sf.cur.value,u.senha_salt)!==u.senha_hash)throw new Error('Senha atual incorreta.');pwCheck(sf.a.value);if(sf.a.value!==sf.b.value)throw new Error('As senhas não conferem.');await upd({...await pwFields(sf.a.value),troca_senha:false},'alterou a própria senha');sf.reset();toast('Senha alterada com sucesso')})}}; /* ===== Painel do ADM ===== */ const TEMAS={'Azul Oceano':['#0a2f6b','#1565d8','#38bdf8'],'Azul Royal':['#1e1b6b','#2f4fe0','#60a5fa'],'Marinho Executivo':['#0b1f3a','#1f4e8c','#7dd3fc'],'Turquesa':['#063b4c','#0e7490','#2dd4bf'],'Céu Claro':['#1d4ed8','#3b82f6','#93c5fd'],'Grafite Azul':['#111827','#334155','#60a5fa'],'Esmeralda':['#064e3b','#047857','#34d399'],'Vinho':['#4c0519','#9f1239','#fb7185']}; const TABLES=['Usuario','Config','Morador','Veiculo','ReservaSalao','ReservaAcademia','Manutencao','OrdemServico','Funcionario','Ponto','Material','Movimento','Documento','DocParte','Mensagem','Notificacao','Sugestao','Lembrete','Ciencia','Historico']; VIEWS.admin=async v=>{v.innerHTML=tabs([['r','Relatórios','chart-bar'],['a','Aparência','palette'],['m','Menu','list'],['p','Equipes e permissões','lock-key'],['w','WhatsApp','whatsapp-logo'],['g','Galeria de fotos','images'],['h','Auditoria','clock-counter-clockwise'],['b','Backup','database']])+'
';const c=$('#adc',v);const show=k=>({r:aRel,a:aLook,m:aMenu,p:aPerm,w:aWa,g:aGal,h:aAud,b:aBak})[k](c);wireTabs(v,show);await aRel(c)}; async function aRel(c){c.innerHTML='
';const g=t=>db.list(t).catch(()=>[]);const [mor,vei,os,man,mat,rs,ra,sug,msg,pon]=await Promise.all(['Morador','Veiculo','OrdemServico','Manutencao','Material','ReservaSalao','ReservaAcademia','Sugestao','Mensagem','Ponto'].map(g));const cnt=(a,k)=>a.reduce((o,x)=>(o[x[k]||'—']=(o[x[k]||'—']||0)+1,o),{}); const blk=(t,obj)=>`

${t}

${Object.entries(obj).map(([k,n])=>`
${badge(k)||esc(k)}${n}
`).join('')||'

Sem dados.

'}
`; const tot=[['Usuários ativos',S.users.filter(u=>u.ativo!==false).length],['Online agora',S.users.filter(online).length],['Moradores',mor.length],['Veículos',vei.length],['Ordens de serviço',os.length],['Manutenções',man.length],['Materiais',mat.length],['Itens com estoque baixo',mat.filter(lowStock).length],['Reservas do salão',rs.length],['Agendamentos academia',ra.length],['Sugestões',sug.length],['Mensagens',msg.length],['Registros de ponto',pon.length]]; c.innerHTML=`

Visão geral do condomínio

${tot.map(([l,n],i)=>`
${n}${l}
`).join('')}
${blk('Ordens de serviço por status',cnt(os,'status'))}${blk('Manutenções por situação',cnt(man,'situacao'))}
${blk('Manutenções por categoria',cnt(man,'categoria'))}${blk('Sugestões por status',cnt(sug,'status'))}
${blk('Usuários por perfil',cnt(S.users.map(u=>({p:ROLE[u.papel]})),'p'))}${blk('Usuários por equipe',cnt(S.users,'equipe'))}
`; $('#rp',c).onclick=()=>printDoc('Relatório geral',`

Relatório geral do condomínio

${tot.map(([l,n])=>``).join('')}
IndicadorQuantidade
${l}${n}
${[['OS por status',cnt(os,'status')],['Manutenções por situação',cnt(man,'situacao')],['Manutenções por categoria',cnt(man,'categoria')]].map(([t,o])=>`

${t}

${Object.entries(o).map(([k,n])=>``).join('')}
${esc(k)}${n}
`).join('')}`)} function aLook(c){const p=cfg('predio'),t=cfg('tema');c.innerHTML=`

Identificação e logotipo

${logoHTML('lg-logo')}
${p.logo?'':''}

O logotipo é salvo em PNG de alta resolução (até 1200px, preservando transparência) e aparece no menu, login, impressões e documentos.

Cores e tema

${Object.entries(TEMAS).map(([n,cs])=>``).join('')}

Imagem de fundo

Aparece na tela de login, no painel inicial e (opcional) no fundo de todas as telas. Envie do computador para enquadrar em HD (16:9, até 2560px) ou use um endereço da internet.

${t.bg?'':''}
`; const sv=async(k,val,msg)=>{await saveCfg(k,val);applyTheme();audit('admin','personalização',k,msg);toast('Personalização salva');startApp();setTimeout(()=>{location.hash='#/admin';},0)}; const pf=$('#pf3',c);pf.onsubmit=e=>{e.preventDefault();sv('predio',{...cfg('predio'),nome:pf.nome.value.trim()||'Condomínio'},'Nome alterado')}; const urlImg=async(o,apply)=>{const u=await promptBox('Imagem por endereço da internet','Endereço da imagem (https://…)','https://');if(!u||!/^https:\/\//.test(u))return u&&toast('Use um endereço https://','err');const b=await cropper(u,o);if(b){toast('Enviando…','info');apply(await upload(b,o.png?'logo.png':'fundo.jpg'))}else if(await confirmBox('Não foi possível enquadrar esta imagem (o site de origem bloqueia edição). Usar o endereço diretamente?','Usar assim mesmo',false))apply(u)}; $('#lup',c).onclick=async()=>{try{const u=await pickImage({png:true,outW:1200,allowOriginal:true,title:'Enquadrar logotipo'});if(u)sv('predio',{...cfg('predio'),logo:u},'Logotipo alterado')}catch(e){toast(e.message,'err')}}; $('#lurl',c).onclick=()=>urlImg({png:true,outW:1200,title:'Enquadrar logotipo'},u=>sv('predio',{...cfg('predio'),logo:u},'Logotipo por URL')); $('#lrm',c)&&($('#lrm',c).onclick=()=>sv('predio',{...cfg('predio'),logo:''},'Logotipo removido')); $('[data-tema]',c).forEach(b=>b.onclick=()=>{const [a,b2,d]=TEMAS[b.dataset.tema];sv('tema',{...cfg('tema'),p1:a,p2:b2,p3:d},'Tema '+b.dataset.tema)}); $('#csave',c).onclick=()=>sv('tema',{...cfg('tema'),p1:$('#c1',c).value,p2:$('#c2',c).value,p3:$('#c3',c).value},'Cores personalizadas'); $('#bup',c).onclick=async()=>{try{const u=await pickImage({aspect:16/9,outW:2560,title:'Enquadrar imagem de fundo (HD)'});if(u)sv('tema',{...cfg('tema'),bg:u},'Imagem de fundo enviada')}catch(e){toast(e.message,'err')}}; $('#burl',c).onclick=()=>urlImg({aspect:16/9,outW:2560,title:'Enquadrar imagem de fundo'},u=>sv('tema',{...cfg('tema'),bg:u},'Imagem de fundo por URL')); $('#brm',c)&&($('#brm',c).onclick=()=>sv('tema',{...cfg('tema'),bg:''},'Imagem de fundo removida')); $('#bpos',c).onchange=e=>sv('tema',{...cfg('tema'),bgPos:e.target.value},'Enquadramento do fundo');$('#bapp',c).onchange=e=>sv('tema',{...cfg('tema'),bgApp:e.target.checked},'Fundo nas telas')} function aMenu(c){let items=menuItems().map(m=>({k:m.k,l:m.l,hidden:!!m.hidden,i:m.i}));const mode=cfg('menu').view||'lista'; const draw=()=>{c.innerHTML=`

Modo de exibição

${[['lista','Lista','list','Ícone e nome lado a lado'],['icones','Ícones','squares-four','Menu compacto, só ícones'],['legendas','Ícones com legendas','grid-four','Blocos com ícone e legenda']].map(([k,l,i,d])=>``).join('')}

Ordem, nomes e visibilidade

${items.map((m,i)=>`
`).join('')}
`; const sync=()=>$('[data-i]',c).forEach(r=>{const m=items[+r.dataset.i];m.l=$('[data-l]',r).value.trim()||MENU.find(x=>x.k===m.k).l;m.hidden=!$('[data-v]',r).checked}); $('[data-i]',c).forEach(r=>{const i=+r.dataset.i;$('[data-up]',r).onclick=()=>{sync();[items[i-1],items[i]]=[items[i],items[i-1]];draw()};$('[data-dn]',r).onclick=()=>{sync();[items[i+1],items[i]]=[items[i],items[i+1]];draw()}}); $('#msv',c).onclick=()=>busy($('#msv',c),async()=>{sync();await saveCfg('menu',{view:$('input[name=vm]:checked',c).value,items:items.map(({k,l,hidden})=>({k,l,hidden}))});audit('admin','personalizou menu','','');toast('Menu salvo');startApp()}); $('#mrs',c).onclick=()=>busy($('#mrs',c),async()=>{await saveCfg('menu',{view:'lista',items:[]});toast('Menu restaurado');startApp()})};draw()} function aPerm(c){let eqs=cfg('equipes');const lbl=k=>MENU.find(m=>m.k===k).l;const draw=()=>{c.innerHTML=`

Permissões por equipe

Funcionários acessam apenas os módulos marcados para a equipe deles. ADM e Gerentes acessam todos os módulos operacionais. Mensagens, Sugestões, Lembretes, Ponto e Perfil estão sempre disponíveis. Somente o ADM acessa o Painel do ADM; ADM e Gerentes acessam Usuários e Acessos.

${PERMODS.map(k=>``).join('')}${eqs.map((e,i)=>`${PERMODS.map(k=>``).join('')}`).join('')}
Equipe${lbl(k)}
${esc(e.nome)}
${S.users.filter(u=>u.equipe===e.nome).length} usuário(s)
`; const sync=()=>$('tr[data-i]',c).forEach(r=>{eqs[+r.dataset.i].modulos=$('[data-k]',r).filter(x=>x.checked).map(x=>x.dataset.k)}); $('[data-del]',c).forEach(b=>b.onclick=()=>{sync();eqs.splice(+b.closest('tr').dataset.i,1);draw()}); $('#eadd',c).onclick=async()=>{sync();const n=await promptBox('Nova equipe','Nome da equipe');if(n&&!eqs.some(e=>e.nome===n)){eqs.push({nome:n,modulos:[]});draw()}}; $('#esv',c).onclick=()=>busy($('#esv',c),async()=>{sync();await saveCfg('equipes',eqs);audit('admin','alterou permissões','',eqs.map(e=>`${e.nome}: ${e.modulos.join(',')||'nenhum'}`).join(' | '));toast('Permissões salvas');drawNav()})};draw()} function aWa(c){const w=cfg('whatsapp');c.innerHTML=`

WhatsApp do administrador

${digits(w.numero)?` Testar link`:''}
Como as notificações funcionam hoje:
✅ No sistema (automático e real): toda mensagem enviada gera uma notificação para o ADM (sino no topo), e as conversas diretas notificam o destinatário.
✅ WhatsApp por link: o botão abre o WhatsApp com o texto pronto; quem envia confirma no aplicativo.
⏳ Envio automático pelo WhatsApp: ainda não está ativo. Ele exige a API oficial do WhatsApp Business (via Twilio, cobrada na conta do condomínio). Nenhuma notificação de WhatsApp é simulada.
`; const f=$('#wf',c);f.onsubmit=e=>{e.preventDefault();const n=f.n.value.trim();if(n&&digits(n).length<12)return toast('Inclua o código do país (55) e o DDD.','err');busy($('[type=submit]',f),async()=>{await saveCfg('whatsapp',{numero:n,botao:f.b.checked});audit('admin','alterou WhatsApp','',n);toast('WhatsApp salvo');aWa(c)})}} function aGal(c){const us=[...S.users].sort((a,b)=>!!b.foto-!!a.foto||a.nome.localeCompare(b.nome));c.innerHTML=`

Galeria de fotos dos usuários

${us.filter(u=>u.foto).length} de ${us.length} usuários com foto. Clique para ampliar.

${us.map(u=>`
${u.foto?`${esc(u.nome)}`:av(u,110)}${esc(u.nome)}${ROLE[u.papel]}
`).join('')}
`;c.onclick=e=>{const i=e.target.closest('[data-img]');if(i)viewImg(i.src)}} async function aAud(c){c.innerHTML='
';const hs=(await db.list('Historico',{sort:'created_at',order:'desc'},3000)).sort((a,b)=>b.id-a.id);const mods=[...new Set(hs.map(h=>h.modulo))].sort(); c.innerHTML=`
`; const rows=()=>{const s=norm($('#aq',c).value),m=$('#am',c).value,d=$('#a1',c).value;return hs.filter(h=>(!m||h.modulo===m)&&(!d||String(h.quando).slice(0,10)>=d)&&(!s||norm(`${h.acao} ${h.usuario} ${h.detalhes}`).includes(s)))}; const draw=()=>{const l=rows().slice(0,500);$('#ab',c).innerHTML=l.length?`${l.map(h=>``).join('')}
Data / horaUsuárioMóduloAçãoDetalhes
${fmtDT(h.quando)}${esc(h.usuario)} ${ROLE[h.papel]||''}${esc(h.modulo)}${esc(h.acao)}${esc(h.detalhes)}
${rows().length} registro(s)${rows().length>500?' — exibindo os 500 mais recentes':''}
`:'

Nenhum registro.

'}; $('input,select',c).forEach(e=>e.oninput=draw);draw();$('#ap',c).onclick=()=>printTable('Registro de auditoria',['Data/hora','Usuário','Módulo','Ação','Detalhes'],rows().map(h=>[fmtDT(h.quando),h.usuario,h.modulo,h.acao,h.detalhes]))} function aBak(c){c.innerHTML=`

Baixar backup completo

Gera um arquivo JSON com todas as tabelas (usuários, moradores, reservas, manutenção, OS, estoque, documentos, mensagens, sugestões, lembretes, auditoria e configurações). Guarde em local seguro: ele contém dados pessoais e os hashes das senhas.

Restaurar registros

Envie um backup gerado aqui. Somente os registros que não existem mais (por exemplo, excluídos por engano) são recriados — nada é sobrescrito.

`; $('#bk',c).onclick=()=>busy($('#bk',c),async()=>{const out={sistema:'Gestão Predial',gerado:nowISO(),por:S.user.nome,tabelas:{}};for(const t of TABLES)out.tabelas[t]=await db.list(t,{},50000);dlBlob(`backup-gestao-predial-${today()}.json`,new Blob([JSON.stringify(out)],{type:'application/json'}));audit('admin','gerou backup','',Object.entries(out.tabelas).map(([k,v])=>k+':'+v.length).join(' '));toast('Backup gerado')}); $('#rs',c).onclick=async()=>{const f=await pickFile('.json,application/json');if(!f)return;let d;try{d=JSON.parse(await f.text())}catch(e){return toast('Arquivo inválido','err')}if(!d.tabelas)return toast('Arquivo não é um backup do sistema','err');if(!await confirmBox('Recriar os registros do backup que não existem mais no sistema?','Restaurar',false))return;const log=$('#rsl',c);let n=0; for(const t of TABLES){const rs=d.tabelas[t]||[];if(!rs.length)continue;log.textContent='Verificando '+t+'…';const ex=new Set((await db.list(t,{},50000)).map(r=>r.id));for(const r of rs){if(ex.has(r.id))continue;const {id,created_at,updated_at,created_by,...rest}=r;try{await db.create(t,rest);n++}catch(e){}}} log.textContent=`Restauração concluída: ${n} registro(s) recriado(s).`;audit('admin','restaurou backup','',n+' registros');toast('Restauração concluída')}} window.addEventListener('load',boot);