Clique para escolher ou arraste aqui — Word, PDF, imagem, Excel (até 4 MB por arquivo)
Registro
Esta série
Histórico de alterações
Comentários e atualizações
· obrigatório neste status
Ao deixar o registro em espera ou aguardando retorno, explique o motivo — é o que permite à equipe retomar sem perguntar.
Digite @ para chamar alguém da equipe — quem for mencionado recebe o aviso na hora.
Registre o motivo antes de salvar.
Plataforma
Configuração
Equipe
Novo integrante
Identificação
?
JPG ou PNG, recorte quadrado fica melhor.
Informe o nome.
Selecione a função.
No dia do aniversário, a plataforma cumprimenta a pessoa no primeiro acesso — e a data aparece na Gestão de Pessoas.
Só quem está Ativo consegue entrar na plataforma. Quem aguarda liberação já pode criar a senha e receber tarefas, mas só passa da tela de login quando a Administração ativar. Inativo sai das listas.
Com OAB e UF preenchidos, a plataforma poderá buscar processos vinculados a esse registro quando a integração com os tribunais for ligada.
Contato
Informe um e-mail válido.
Permissões o que esta pessoa enxerga e faz dentro da plataforma
Quem tem "Meu acesso" também não vê o cadastro nem as atribuições dos outros integrantes.
Não perturbe definido pela Administração
Durante essa janela o servidor segura os e-mails desta pessoa e só os entrega no horário de retomada. Os avisos dentro da plataforma continuam aparecendo normalmente.
Utilitário
Gerador de senhas
Somente suas senhas
Histórico de senhas geradas
O histórico é particular: cada pessoa vê apenas as senhas que ela mesma gerou.
Gerador de documentos · marcadores
O norte do escritório. Só a Administração edita; os sócios comentam.
Administrativo · Metas
Metas · medição
Metas · KPI
Metas
Este dossiê é da gestão. Nada aqui aparece para o colaborador.
Administrativo · Gestão de Pessoas
🎂
Feliz aniversário!
Administrativo · compartilhamento
Compartilhar com pessoas escolhidas
IA Schneider & Bahnert
Modelo e chave
IA Schneider & Bahnert
Histórico de pesquisas
Cadastro
Novo cliente
Identificação
Sempre em maiúsculas — o campo converte automaticamente.
Informe o nome, todo em letras maiúsculas.
O formato é reconhecido pela quantidade de dígitos.
Documento inválido.
Informe o telefone com DDD.
Opcional. É ela que põe o cliente na lista de aniversariantes.
Informe a senha ou marque “Não é necessário”.
Sempre em maiúsculas — o campo converte automaticamente.
Informe o nome, todo em letras maiúsculas.
CPF inválido.
Informe o vínculo.
Descreva o vínculo.
Os dados do representante viajam junto com o cliente: ao lançar um recebível na Gestão Financeira, a plataforma oferece esse nome e CPF como titular da cobrança.
Situação na carteira
Informe por que este cliente saiu da carteira.
Classificação do caso
Selecione a área.
Pode marcar mais de um — por exemplo, Aposentadoria por Idade Rural e Pensão por Morte no mesmo caso.
Selecione ao menos um tipo de ação.
Selecione a origem.
Selecione o estado.
Informe a data.
Vem de Gestão Financeira › Advogados parceiros e associados. Ao escolher, informe
quanto do contrato será repassado — é esse percentual que a tarefa financeira vai puxar depois.
Quem atua neste caso, entre os cadastrados na equipe. O percentual registra a parte
de cada um no retorno financeiro do contrato.
Preenchido pela busca. Pode ser editado ou apagado à mão.
Informe a pasta ou marque “Não há”.
Acompanhamento processual judicial, administrativo ou extrajudicial
Vale para processo judicial, requerimento no INSS, acordo extrajudicial — qualquer via
que o caso tenha seguido. É este status que aparece na carteira de clientes.
Atualizado pelas movimentações; pode ser ajustado à mão.
se houver processo Dados do processo judicial
Informe o número ou marque “Não tem processo judicial”.
Link direto do processo no sistema do tribunal.
Obrigatório quando há processo.
Cada linha que começar com uma data vira uma movimentação. Datas em dd/mm/aaaa.
Observações
Registro
Comentários e atualizações
Digite @ para chamar alguém da equipe — quem for mencionado recebe o aviso na hora.
Histórico de alterações
Agenda
Compromisso da série
Gestão Financeira
Cópias guardadas no servidor
A cada gravação, o servidor guarda a base que estava lá antes — até doze cópias da
Gestão Financeira, uma a cada dez minutos. É daqui que se recupera um lançamento que desapareceu.
Carregando…
Gestão Financeira
Cadastrar como recebível
Escolha o tipo. Em seguida o cadastro de recebimento abre já preenchido com o que
este registro tem: cliente, documento, área, emissão de NF e os repasses combinados.
Os valores e as datas você preenche no cadastro, com a descrição à vista.
Agenda
Novo compromisso
Campo obrigatório.
Compromisso externo exige horário.
Campo obrigatório.
Campo obrigatório.
Campo obrigatório.
Sem término, reserva uma hora.
Escolha uma cor para bater o olho e reconhecer o compromisso. Sem escolha, vale a cor do tipo.
Quem for marcado enxerga o compromisso na própria agenda.
Registro
Ata
Ata da reunião
Status
Em espera ou aguardando retorno
O texto entra nos comentários do registro, com seu nome e o horário.
Escreva o motivo para continuar.
Obrigatório neste status.
Indicadores
Relatório gerado
O arquivo foi enviado para a pasta de downloads do navegador.
Ele abre direto no Excel — as colunas já vêm separadas por ponto e vírgula, o padrão brasileiro.
Se o download não aparecer, use o botão Copiar conteúdo e cole numa planilha em branco.
O relatório segue exatamente os filtros que estão na tela.
Acesso rápido
Contato da equipe
Clique no e-mail para escrever ou use o botão para copiar.
Clique em uma linha para abrir o registro.
O encaminhamento fica registrado no histórico do registro.
Gestão Financeira
'+
'
'+txt+'
'+(botoes||'')+'' : '';
}
/* O módulo é uma página inteira. Tentamos três caminhos, porque o ambiente do
artefato pode bloquear um deles: escrever no iframe, srcdoc e blob. */
/* Há uma janela aberta dentro do módulo financeiro? Reconstruir o quadro
nessa hora apaga o que a pessoa está digitando — foi assim que cadastros
inteiros se perderam: a base nova chegava do servidor no meio do
preenchimento, o quadro era refeito e o formulário sumia junto. */
function finOcupado(){
try{
const d=$('fin-frame').contentDocument;
if(!d || !d.querySelector) return false;
return !!d.querySelector('.ov.on');
}catch(e){ return false; }
}
/* Em vez de refazer o quadro, pede ao módulo que releia a base: ele já sabe
juntar o que chegou com o que foi feito aqui, e não encosta no que estiver
aberto na tela. Só quando isso não é possível é que o quadro é refeito. */
function finAtualizarFrame(){
try{
const w=$('fin-frame').contentWindow;
if(w && typeof w.finRecarregar==='function'){ w.finRecarregar(); return true; }
}catch(e){}
return false;
}
/* Recarga adiada: com janela aberta, espera ela fechar. */
var FIN_REFAZER_PEND = false;
function finRefazerQuandoPuder(){
if(!finOcupado()){ FIN_REFAZER_PEND=false; montarFinanceiro(true, true); return; }
if(FIN_REFAZER_PEND) return;
FIN_REFAZER_PEND = true;
var t = setInterval(function(){
if(finOcupado()) return;
clearInterval(t); FIN_REFAZER_PEND=false;
if(UI.painel==='financeiro') montarFinanceiro(true, true);
}, 1500);
}
function montarFinanceiro(forcar, agora){
if(FIN_PRONTO && !forcar) return;
/* refazer com janela aberta apaga o trabalho em curso */
if(FIN_PRONTO && forcar && !agora && finOcupado()){ finRefazerQuandoPuder(); return; }
const html = finHTML();
if(!html){ finStatus('O módulo financeiro não foi encontrado neste arquivo.'); return; }
try{ localStorage.setItem('gf_tema', S.cfg.tema==='dark'?'dark':'light'); }catch(e){}
gravarUsuarioFin();
/* o módulo precisa saber se há servidor: com servidor, o retrato de fábrica
embutido neste arquivo nunca entra em cena */
try{ localStorage.setItem('gf_com_servidor', SRV.ativo?'1':''); }catch(e){}
FIN_PRONTO=false; FIN_TENTATIVA=0;
finStatus('Abrindo a plataforma financeira…');
tentarFin(html);
}
function tentarFin(html){
const fr = $('fin-frame');
FIN_TENTATIVA++;
try{
if(FIN_TENTATIVA===1){
fr.removeAttribute('srcdoc'); fr.src='about:blank';
setTimeout(()=>{ try{
const d=fr.contentDocument; d.open(); d.write(html); d.close();
}catch(e){} verificarFin(html); }, 60);
return;
}
if(FIN_TENTATIVA===2){ fr.removeAttribute('src'); fr.srcdoc = html; }
else if(FIN_TENTATIVA===3){
fr.removeAttribute('srcdoc');
FIN_URL = URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'}));
fr.src = FIN_URL;
} else {
finStatus('Este navegador está bloqueando a exibição do módulo financeiro dentro da plataforma. '+
'Você pode abri-lo em uma aba separada — os dados são os mesmos.',
'
'+
''+
'
');
return;
}
}catch(e){}
verificarFin(html);
}
function verificarFin(html){
setTimeout(()=>{
if(finCarregou()){ FIN_PRONTO=true; finStatus(''); sincronizarTemaFin(); sincronizarPermissaoFin(); renderMarca();
renderPopNotif(); /* agora os vencimentos do módulo entram na pilha */
setTimeout(()=>sincronizarAssociados(true), 400);
setTimeout(function(){ try{ verificarNotas(); }catch(e){} }, 500);
/* Servidor ainda sem base financeira: sobe a que existe aqui — mas só
depois de o servidor ter sido lido com sucesso ao menos uma vez. Sem
essa condição, um navegador que entrou sem conseguir ler subiria a
própria cópia (ou o retrato de fábrica) por cima da base boa. */
setTimeout(function(){
if(finPodeSincronizar() && SRV.leuServidor && !SRV.versao.financeiro) srvAgendar('financeiro');
}, 700); }
else tentarFin(html);
}, 1400);
}
function alternarFinFull(){
const box=$('fin-box'), cheio=!box.classList.contains('full');
box.classList.toggle('full',cheio);
document.body.classList.toggle('finfull',cheio);
$('fin-btn-full').innerHTML = cheio?'⤡ Sair da tela cheia':'⛶ Tela cheia';
$('fin-btn-full').title = cheio?'Voltar ao tamanho normal (Esc)':'Ampliar para tela cheia';
$('fin-btitulo').textContent = cheio?'Gestão Financeira · tela cheia' : 'Gestão Financeira';
ajustarBarraFin();
/* A tela cheia do navegador é pedida para a PÁGINA, não para o quadro: com
o quadro como elemento em tela cheia, nada que estivesse fora dele era
desenhado — nem a confirmação de dispensa, nem a janela do registro. */
if(cheio){ try{ const alvo=document.documentElement;
if(alvo.requestFullscreen) alvo.requestFullscreen({navigationUI:'hide'}); }catch(e){} }
else { try{ if(document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); }catch(e){} }
}
/* Quem está registrando uma cobrança precisa assinar o registro, e o módulo
financeiro é uma página à parte: não enxerga quem entrou aqui. O nome é
deixado nesta chave — a mesma via do tema — e serve também quando o
módulo é aberto em aba separada. */
function gravarUsuarioFin(){
try{
const u = eu();
localStorage.setItem('gf_usuario', JSON.stringify(u ? {id:u.id, nome:u.nome, funcao:u.funcao||''} : null));
}catch(e){}
/* O módulo precisa saber para onde mandar os anexos. O endereço não é
segredo; a senha da sessão continua onde sempre esteve. */
try{
localStorage.setItem('gf_srv', JSON.stringify({ativo: !!SRV.ativo, url: SRV.url || srvURL()}));
}catch(e){}
}
function sairFinFull(){ if($('fin-box') && $('fin-box').classList.contains('full')) alternarFinFull(); }
/* O módulo, lá de dentro, pede a tela cheia quando a janela de detalhe é
ampliada — e devolve o tamanho normal quando ela é fechada. */
window.finEstaFull = function(){ const b=$('fin-box'); return !!(b && b.classList.contains('full')); };
window.finEntrarFull = function(){ if(!window.finEstaFull()) alternarFinFull(); };
window.finSairFull = function(){ if(window.finEstaFull()) alternarFinFull(); };
/* e a moldura recua enquanto a janela estiver ocupando tudo */
window.finJanelaCheia = function(on){ document.body.classList.toggle('finmodal', !!on); };
document.addEventListener('fullscreenchange', ()=>{
const box=$('fin-box');
if(!document.fullscreenElement && box && box.classList.contains('full') && UI.fullNativo) sairFinFull();
UI.fullNativo = !!document.fullscreenElement;
});
function abrirFinNovaAba(){
try{
if(!FIN_URL) FIN_URL = URL.createObjectURL(new Blob([finHTML()],{type:'text/html;charset=utf-8'}));
const a=document.createElement('a'); a.href=FIN_URL; a.target='_blank'; a.rel='noopener';
document.body.appendChild(a); a.click(); a.remove();
}catch(e){ toast('Não foi possível abrir em outra aba.'); }
}
/* O módulo financeiro guarda o próprio tema em 'gf_tema'. Escrevemos a chave antes
de carregá-lo e, com ele já aberto, alternamos a classe direto no documento. */
function sincronizarTemaFin(){
const escuro = S.cfg.tema==='dark';
try{ localStorage.setItem('gf_tema', escuro?'dark':'light'); }catch(e){}
try{
const w = $('fin-frame').contentWindow;
if(w && typeof w.aplicarTema==='function'){ w.aplicarTema(escuro); return; } /* redesenha também os gráficos */
}catch(e){}
try{ const d = $('fin-frame').contentDocument; if(d && d.body) d.body.classList.toggle('dark', escuro); }catch(e){}
}
/* O módulo é uma página à parte: o nível de acesso é entregue a ele assim que
termina de carregar, e repetido a cada entrada no painel. */
function finAvisoAbrir(tipo, id){
try{ const w=$('fin-frame').contentWindow;
if(w && typeof w.finAbrirAviso==='function'){ w.finAbrirAviso(tipo, id); return; } }catch(e){}
toast('Abra a Gestão Financeira para dar a baixa.');
}
function finAvisoAdiar(id){
try{ const w=$('fin-frame').contentWindow;
if(w && typeof w.finAdiarAviso==='function' && w.finAdiarAviso(id)){
renderPopNotif(); toast('Aviso adiado para o próximo ciclo.'); return; } }catch(e){}
toast('Não consegui adiar este aviso agora.');
}
function sincronizarPermissaoFin(){
const nivel = nivelFinanceiro();
const bar = $('fin-ro'); if(bar) bar.style.display = (nivel==='leitura') ? '' : 'none';
try{
const w = $('fin-frame').contentWindow;
if(w && typeof w.aplicarPermissaoFin==='function') w.aplicarPermissaoFin(nivel);
/* embutido na plataforma, quem mostra os vencimentos é a pilha de avisos daqui */
if(w && typeof w.finMostrarNotificacoes==='function') w.finMostrarNotificacoes(false);
}catch(e){}
}
/* Quais das minhas tarefas já vão aparecer como "Lançar na Gestão
Financeira". Um registro financeiro atribuído a mim e ainda não lançado
cabia em dois avisos ao mesmo tempo — o genérico e o do lançamento —, e
a pilha mostrava o mesmo trabalho duas vezes. Vence o do lançamento,
que é o que tem os botões certos. */
function idsPendentesFin(){
const m = {};
try{ if(podeFinanceiro()) finPendentes(true).forEach(t=>{ m[t.id]=true; }); }catch(e){}
return m;
}
/* Pedido feito na aba separada e ainda não aplicado aqui: a tarefa já foi
resolvida lá e não pode continuar cobrando lançamento nesta tela durante os
segundos que a fila leva para ser lida. */
function filaFin(){
const m={};
try{ (JSON.parse(localStorage.getItem('gf_pedidos')||'[]')||[]).forEach(x=>{
if(x && x.tarefa!=null) m[String(x.tarefa)]=1; }); }catch(e){}
return m;
}
/* A lista completa alimenta os painéis; o aviso, só o que é seu. */
function finPendentes(soMeus){
if(!podeFinanceiro()) return [];
return S.tarefas.filter(t=>t.tipo==='financeiro' && t.fin && FIN_LANCAVEIS.indexOf(t.fin.tipo)>=0
&& !t.fin.lancado && !t.fin.baixa && t.status!==CONCLUIDA && visivel(t)
&& !filaFin()[String(t.id)]
&& (!soMeus || souResponsavel(t)));
}
function renderAlertasFin(){
const el=$('fin-alertas'); if(!el) return;
const p = finPendentes().slice().sort(ordena);
if(!p.length){ el.innerHTML=''; return; }
el.innerHTML = '
Pendente de lançamento '+p.length+'
'+
'
'+(p.length===1?'Um registro financeiro criado no painel de tarefas ainda não foi lançado aqui.'
:p.length+' registros financeiros criados no painel de tarefas ainda não foram lançados aqui.')+'
'+esc(t.fin.tipo)+''+
''+esc(t.descricao)+''+
''+
/* quem só consulta o financeiro não lança nem dispensa */
(nivelFinanceiro()==='total'
? ''+
''
: '')+
''+(cli?esc(cli)+' · ':'')+'Criado por '+esc(t.criadoPor||'—')+' em '+fmtDH(t.criadoEm)+
' · '+esc(TIPOS[t.tipo].dataLabel)+' '+fmtD(t.data)+(t.fin.nf==='Sim'?' · emitir NF':'')+'
';
}).join('')+'
';
}
/* Nem toda tarefa financeira vira recebível ou despesa: há as que só pedem
uma conferência. "Dispensar" tira do alerta sem fingir que houve
lançamento — e o histórico registra qual dos dois caminhos foi. */
async function dispensarLancamento(id){
const t=S.tarefas.find(x=>x.id===id); if(!t || !t.fin) return;
if(nivelFinanceiro()!=='total'){ toast('Somente a Administração baixa estes alertas.'); return; }
if(!podeEditar(t)){ toast('Você não pode alterar este registro.'); return; }
if(!await confirmar('Dispensar este registro do lançamento? Ele sai do alerta da Gestão Financeira '+
'sem virar recebível nem despesa. A dispensa fica registrada no histórico.')) return;
t.fin.lancado=true; t.fin.baixa='dispensado';
t.fin.lancadoPor=eu().nome; t.fin.lancadoEm=agoraISO();
hist(t,'Dispensado do lançamento na Gestão Financeira — não exige recebível nem despesa.');
t.alteradoEm=agoraISO(); t.alteradoPor=eu().nome;
salvar(); redesenhar(); toast('Registro dispensado do alerta financeiro.');
}
/* Mantida para as chamadas antigas que ainda apontem para cá. */
function marcarLancado(id){ dispensarLancamento(id); }
/* A Gestão Financeira aberta em aba separada não alcança esta base: o que
foi dispensado lá entra numa fila, que aplicamos aqui. Gravar por fora
atropelaria a mescla entre aparelhos. */
function aplicarPedidosFin(){
/* Sem sessão aberta não há a quem atribuir a baixa: a fila espera. Antes a
chave era apagada de qualquer modo, e o pedido feito na outra aba se
perdia se esta página estivesse parada na tela de entrada. */
if(!S || !S.usuario || !Array.isArray(S.tarefas)) return;
let fila=[];
try{ fila = JSON.parse(localStorage.getItem('gf_pedidos')||'[]')||[]; }catch(e){ return; }
if(!fila.length) return;
let n=0, nLanc=0;
const sobra=[];
fila.forEach(pd=>{
const t=S.tarefas.find(x=>x.id===+(pd&&pd.tarefa));
/* tarefa que esta base ainda não conhece (sincronização a caminho):
o pedido fica na fila para a próxima passagem */
if(!t){ sobra.push(pd); return; }
if(!t.fin){
/* registro sem bloco financeiro: não há o que baixar, e insistir só
encheria a fila. Fica o rastro no console para diagnóstico. */
try{ console.warn('Pedido da aba separada sem bloco financeiro na tarefa', pd); }catch(e){}
return;
}
if(t.fin.lancado) return;
if(pd.acao==='dispensar'){
t.fin.lancado=true; t.fin.baixa='dispensado';
t.fin.lancadoPor=eu().nome; t.fin.lancadoEm=pd.em||agoraISO();
hist(t,'Dispensado do lançamento na Gestão Financeira (aba separada) — não exige recebível nem despesa.');
t.alteradoEm=agoraISO(); t.alteradoPor=eu().nome; n++;
}
/* Recebível cadastrado na aba separada: lá não havia plataforma para
avisar, então a baixa do alerta vinha na fila junto com as dispensas. */
else if(pd.acao==='lancado'){
finBaixarTarefa(t, 'com o cadastro do recebimento na Gestão Financeira (aba separada)', pd.em);
nLanc++;
}
});
try{ if(sobra.length) localStorage.setItem('gf_pedidos', JSON.stringify(sobra));
else localStorage.removeItem('gf_pedidos'); }catch(e){}
if(n || nLanc){
salvar(); redesenhar();
const partes=[];
if(nLanc) partes.push(nLanc===1?'um recebimento cadastrado':nLanc+' recebimentos cadastrados');
if(n) partes.push(n===1?'uma dispensa':n+' dispensas');
toast('Trouxemos da outra aba: '+partes.join(' e ')+'.');
}
}
window.addEventListener('storage', e=>{ if(e && e.key==='gf_pedidos') { try{ aplicarPedidosFin(); }catch(x){} } });
/* ---------------------------------------------------------------
DUAS ABAS DA PLATAFORMA
A Gestão Financeira já ouvia a própria base; a plataforma, não. Concluir
uma tarefa financeira numa aba deixava o alerta de lançamento aceso na
outra até o servidor ser conferido — e, sem servidor, para sempre. O aviso
do navegador chega em segundos e vale também offline.
A base que vem da outra aba é MESCLADA, nunca adotada por inteiro: é a
mesma regra que vale para o servidor, e pelo mesmo motivo.
--------------------------------------------------------------- */
window.addEventListener('storage', function(e){
if(!e || e.key!==KEY || !e.newValue) return;
let outro=null;
try{ outro = JSON.parse(e.newValue); }catch(x){ return; }
if(!outro || !Array.isArray(outro.membros)) return;
/* formulário aberto não é interrompido: a mescla espera a pessoa terminar */
if(temModalAberto()) return;
try{
const juntos = fundirEstado(outro, S);
S = migrar(juntos);
if(SRV.membroId) S.usuario = SRV.membroId;
aplicarListas(); prefAplicar();
redesenhar();
}catch(x){}
});
/* A aba separada da Gestão Financeira roda num endereço de blob, e o aviso de
armazenamento entre ela e esta página não é garantido em todo navegador.
Conferimos a fila de tempos em tempos para não depender só do aviso. */
setInterval(function(){
try{ if(localStorage.getItem('gf_pedidos')) aplicarPedidosFin(); }catch(e){}
/* Sinal de vida da plataforma. Quem está com a Gestão Financeira aberta em
aba separada depende desta aba para que o que ele lança chegue ao
servidor — e precisa saber quando ela não está no ar. */
try{ localStorage.setItem('gescrit_vivo', String(Date.now())); }catch(e){}
}, 3000);
/* Chamada pelo módulo financeiro assim que o recebimento é salvo. */
function finTarefaLancada(idTarefa){
const t=S.tarefas.find(x=>x.id===+idTarefa); if(!t || !t.fin) return;
const proxima = finBaixarTarefa(t, 'com o cadastro do recebimento na Gestão Financeira');
salvar(); redesenhar();
toast('Recebimento cadastrado. O registro financeiro foi concluído e saiu do alerta.'+
(proxima ? ' A próxima ocorrência já está agendada para '+fmtD(proxima.data)+'.' : ''));
}
/* Cadastrado o recebimento, o trabalho daquela tarefa financeira terminou: ela
é concluída e sai do quadro em aberto. Antes ficava apenas "lançada" — some
do alerta do financeiro, mas continua em aberto no painel de tarefas para
sempre, sem nada a fazer nela. */
function finBaixarTarefa(t, comoFoi, quando){
t.fin.lancado=true; t.fin.baixa='lancado';
t.fin.lancadoPor=eu().nome; t.fin.lancadoEm=quando||agoraISO();
hist(t,'Recebimento cadastrado na Gestão Financeira a partir deste registro.');
const hAntes = (t.historico||[]).length;
let proxima = null;
if(t.status!==CONCLUIDA) proxima = concluirTarefa(t, comoFoi||'com o lançamento na Gestão Financeira');
t.alteradoEm=agoraISO(); t.alteradoPor=eu().nome;
try{ cliAndamentoEm(t.clientes, t, cliAndamentoTexto(t, 'atualizado',
(t.historico||[]).slice(hAntes).map(h=>h.txt).join(' · '))); }catch(e){}
return proxima;
}
/* ---------- o que a tarefa financeira tem de aproveitável ---------- */
function dadosDeTarefaFin(t){
const c = (t.clientes||[]).map(id=>cliente(id)).filter(Boolean)[0] || null;
const f = t.fin || {};
/* o percentual combinado pode estar na própria tarefa ou na ficha do cliente */
const pct = {};
if(c){
(c.parceiros||[]).forEach(x=>{ if(x&&x.nome && x.pct!=null && x.pct!=='') pct[String(x.nome).toUpperCase()]=x.pct; });
(c.participantesPct||[]).forEach(x=>{ if(x&&x.nome && x.pct!=null && x.pct!=='') pct[String(x.nome).toUpperCase()]=x.pct; });
}
const daLista = (lista, tipo) => lista.filter(x=>x&&x.nome).map(x=>({
tipo: tipo, nome: x.nome,
perc: (x.pct!=null && x.pct!=='') ? x.pct : (pct[String(x.nome).toUpperCase()] ?? null)
}));
const reps = daLista(finListaDe(f,'parceiros','parceiro'),'PARCEIRO')
.concat(daLista(finListaDe(f,'associados','associado'),'ASSOCIADO'));
const obs = 'Origem: '+TIPOS[t.tipo].kind+' nº '+t.id+' — '+(t.descricao||'')+
(t.criadoPor ? ' (criado por '+t.criadoPor+' em '+fmtDH(t.criadoEm)+')' : '');
return {
tarefaId: t.id, numero: t.id,
tipo: f.tipo || FIN_TIPOS[0],
cliente: c ? c.nome : '',
cpf: c ? (c.cpfCnpj||c.cpf||'') : '',
area: c ? String(c.area||'').toUpperCase() : '',
nf: f.nf==='Sim' ? 'SIM' : (f.nf==='Não' ? 'NÃO' : ''),
obs: obs, repasses: reps, descricao: t.descricao||'',
/* os boletos e carnês anexados à tarefa vão junto, para o campo
de boleto do recebimento */
anexos: (t.arquivos||[]).map(a=>({nome:a.nome, tipo:a.tipo, dataUrl:a.dataUrl})).filter(a=>a.dataUrl)
};
}
/* Passo 1: escolher o tipo de recebível. O da tarefa já vem marcado. */
function cadastrarRecebivel(id){
const t=S.tarefas.find(x=>x.id===id); if(!t || !t.fin) return;
if(nivelFinanceiro()!=='total'){ toast('Somente a Administração lança recebimentos.'); return; }
UI.recebDeTarefa = id;
const atual = t.fin.tipo || '';
$('rt-desc').textContent = t.descricao || '';
$('rt-sub').textContent = 'Registro nº '+t.id+((t.clientes||[]).length
? ' · '+(t.clientes.map(x=>{const c=cliente(x);return c?c.nome:'';}).filter(Boolean).join(', ')) : '');
$('rt-tipos').innerHTML = FIN_TIPOS.map(x=>
'').join('');
abrir('ov-recebtipo');
}
/* Passo 2: leva para a Gestão Financeira e abre o cadastro já preenchido. */
async function abrirRecebivelDaTarefa(tipo){
const t=S.tarefas.find(x=>x.id===UI.recebDeTarefa);
fechar('ov-recebtipo');
if(!t){ toast('Este registro não está mais na base.'); return; }
const dados = dadosDeTarefaFin(t);
dados.tipo = tipo;
if(UI.painel!=='financeiro'){ irPara('financeiro'); }
/* o quadro do financeiro vive num documento à parte: pode ainda estar
carregando quando chegamos aqui */
for(let i=0;i<40;i++){
let w=null;
try{ w = $('fin-frame').contentWindow; }catch(e){}
if(w && typeof w.finNovoDeTarefa==='function'){
if(w.finNovoDeTarefa(dados)) return;
toast('Não consegui abrir o cadastro de recebimento.'); return;
}
await new Promise(r=>setTimeout(r,150));
}
toast('A Gestão Financeira ainda está carregando — tente de novo em instantes.');
}
/* =========================================================
PAINEL INÍCIO — "Minha visão" e "Indicadores"
Reúne, em uma única tela, o que é meu (acompanhamento pessoal)
e o que é do escritório (leitura de gestão, restrita).
========================================================= */
/* Perfis de gestão: mesmo recorte usado na Gestão Financeira. */
function ehGestor(m){ m = m || eu(); return !!m && FUNCOES_FINANCEIRO.indexOf(m.funcao)>=0; }
/* Justificar é obrigatório apenas na janela de tratamento — ali o prazo já está
vencendo e a gestão precisa saber o porquê. Nas alterações comuns o motivo é
opcional, mas a mudança de data e de responsável sempre vai ao histórico. */
function vgEstado(){
if(!UI.vg) UI.vg = {quem:'eu', grupo:'data', prodPer:'semana',
semVazias: !!(S.cfg && S.cfg.vgSemVazias),
indModo:'tipo', indVista:'aberto', indPer:'mes'};
return UI.vg;
}
function vgAlvo(){
const v = vgEstado();
if(!ehGestor() || v.quem==='eu') return S.usuario;
const id = +v.quem;
return membro(id) ? id : S.usuario;
}
function vgEuMesmo(){ return vgAlvo()===S.usuario; }
function vgNomeAlvo(){ const m=membro(vgAlvo()); return m? m.nome : '—'; }
function vgPrimeiroNome(){ return String(vgNomeAlvo()).split(/\s+/)[0]; }
/* ---------- contas de tempo ---------- */
function horasEntre(a,b){
const x=new Date(a), y=new Date(b);
if(!a||!b||isNaN(x)||isNaN(y)) return null;
return (y-x)/3600000;
}
function fmtDur(h){
if(h==null) return '—';
if(h<1) return Math.max(1,Math.round(h*60))+' min';
if(h<48) return (h<10 ? h.toFixed(1).replace('.',',') : Math.round(h))+' h';
const d=h/24;
return (d<10 ? d.toFixed(1).replace('.',',') : Math.round(d))+' dias';
}
/* Dias úteis daqui até a data (0 = hoje, negativo = já passou). */
function diasUteisAte(dataISO){
const d=pd(dataISO); if(!d) return null;
const h=hoje0();
if(+d===+h) return 0;
const sinal = d>h ? 1 : -1;
let n=0, cur=new Date(h.getTime()), guarda=0;
while(+cur!==+d && guarda++<800){
cur.setDate(cur.getDate()+sinal);
const dw=cur.getDay();
if(dw!==0 && dw!==6) n++;
}
return sinal*n;
}
/* Dias úteis decorridos desde um carimbo de data/hora. */
function diasUteisDesde(isoTs){
if(!isoTs) return null;
const d=new Date(isoTs); if(isNaN(d)) return null;
d.setHours(0,0,0,0);
const h=hoje0();
let n=0, cur=new Date(d.getTime()), guarda=0;
while(cur{ if(c.em && (!u || c.em>u)) u=c.em; });
(t.historico||[]).forEach(h=>{
if(h.em && VG_MOV_RE.test(h.txt||'') && !/^registro criado/i.test(h.txt||'')){
if(!u || h.em>u) u=h.em;
}
});
return u;
}
/* "Parada": entrega a até 2 dias úteis e sem nenhum sinal de trabalho
nos últimos 2 dias úteis (nem comentário, nem mudança de status). */
function estaParada(t){
if(t.status===CONCLUIDA) return false;
const du = diasUteisAte(t.data);
if(du===null || du<0 || du>2) return false; /* os vencidos têm alerta próprio */
const mov = ultimaMovimentacao(t);
if(!mov) return diasUteisDesde(t.criadoEm)>=1 || du<=0;
return diasUteisDesde(mov) >= 2;
}
/* ---------- recortes ---------- */
function vgTarefasDe(alvo){
return S.tarefas.filter(t=>{
if(!visivel(t)) return false;
if(!finPodeVer(t, alvo)) return false;
return (t.responsaveis||[]).indexOf(alvo)>=0;
});
}
function vgCompsDe(alvo){
return (S.compromissos||[]).filter(c=> c.donoId===alvo || (c.participantes||[]).indexOf(alvo)>=0 );
}
function compOculto(c){
if(c.donoId===S.usuario || (c.participantes||[]).indexOf(S.usuario)>=0) return false;
if(c.particular) return true;
return agConfig(membro(c.donoId)).visivel==='ocupado';
}
/* =========================================================
ESQUELETO
========================================================= */
function montarVisao(){
const el=$('pane-visao'); if(!el) return;
el.innerHTML =
''+
'
Parte 1 · o seu trabalho'+
'
Minha visão
'+
''+
'
'+
''+
'
1
'+
'
Quanto há em aberto em cada frente de trabalho. Clique em um quadro para abrir a lista completa.
O retrato comercial do escritório: quantos contratos foram assinados, de onde vieram os clientes '+
'e até onde o escritório chegou. Escolha o mês e o ano — todo período já cadastrado continua '+
'disponível, então este quadro serve também como registro histórico.
'+
'
Período analisado'+
'
'+
''+
''+
'
'+
''+
''+
''+
'
'+
'
'+
'
2Demandas por responsável
'+
'
Cada linha é uma pessoa do escritório; cada coluna, um recorte do trabalho dela. '+
'Alterne entre tipo de demanda e status para descobrir onde o trabalho está parado. '+
'Clique em qualquer número para abrir a lista dos registros.
'+
'
Recorte exibido'+
''+
''+
''+
''+
'
'+
''+
''+
'
'+
'
3Produtividade da equipe
'+
'
Quem concluiu mais no período e com que rapidez. O intervalo entre movimentações mostra o ritmo '+
'de migração de status: quanto menor, mais fluida está a demanda na mão da pessoa.
'+
''+
'
4Sugestões de melhoria e alertas
'+
'
Pontos que merecem uma conversa com o responsável antes de virarem urgência. '+
'Os atalhos com o nome de cada pessoa abrem só os registros dela.
'+
'
'+
'';
ligarArrasteLateral($('vg-kanban'));
}
function vgSet(campo, valor){ vgEstado()[campo]=valor; renderVisao(); }
function vgSetInd(campo, valor){ vgEstado()[campo]=valor; renderVisao(); }
/* =========================================================
RENDER GERAL
========================================================= */
function renderVisao(){
if(!$('vg-sec-minha')) montarVisao();
const v=vgEstado(), u=eu();
renderCabecalho(); /* a saudação e a data vivem no cabeçalho */
/* seletor de pessoa — só para a gestão */
$('vg-minha-tools').innerHTML = ehGestor()
? 'Ver as demandas de'+
''
: '';
$('vg-minha-sub').textContent = vgEuMesmo()
? 'Somente o que está sob sua responsabilidade — os três painéis de trabalho reunidos aqui.'
: 'Acompanhamento das demandas de '+vgNomeAlvo()+' (leitura da gestão).';
const meu = vgEuMesmo(), nome = vgPrimeiroNome();
$('vg-bh1').textContent = meu ? 'Minhas demandas' : 'Demandas de '+nome;
$('vg-bh2').textContent = meu ? 'Meu quadro de trabalho' : 'Quadro de trabalho de '+nome;
$('vg-bh3').textContent = meu ? 'Minha produtividade' : 'Produtividade de '+nome;
$('vg-hint3').textContent = 'Quanto foi concluído em cada período e quanto tempo cada registro levou entre a criação e a conclusão.';
renderVgFaixa();
renderVgCaixas();
renderVgBarra();
renderVgKanban();
renderVgProdutividade();
const ind = ehGestor() && pode('ver_visao');
$('vg-sec-ind').style.display = ind ? '' : 'none';
if(ind) renderVgIndicadores();
}
/* ---------- faixa de atenção: o resumo do dia em uma linha ---------- */
function renderVgFaixa(){
const abertas = vgAbertas();
const comps = vgCompsDe(vgAlvo());
const grupos = {
atraso:{cls:'f-al', ic:'⚠', txt:'em atraso', itens:abertas.filter(t=>{const d=dias(t.data);return d!==null&&d<0;})},
hoje: {cls:'f-hj', ic:'●', txt:'com entrega hoje', itens:abertas.filter(t=>dias(t.data)===0)},
sete: {cls:'f-7d', ic:'📅',txt:'nos próximos 7 dias', itens:abertas.filter(t=>{const d=dias(t.data);return d!==null&&d>0&&d<=7;})},
espera:{cls:'f-es', ic:'⏸', txt:'aguardando retorno', itens:abertas.filter(t=>t.status===ESPERA)},
semdata:{cls:'f-sd',ic:'○', txt:'sem data definida', itens:abertas.filter(t=>!t.data)}
};
const hj = comps.filter(c=>!c.concluido && dias(c.data)===0);
let h = Object.keys(grupos).filter(k=>grupos[k].itens.length).map(k=>{
const g=grupos[k];
return '';
}).join('');
if(hj.length) h += '';
if(!h) h = '';
$('vg-faixa').innerHTML = h;
}
function vgFaixaLista(k){
const abertas = vgAbertas();
const def = {
atraso :{t:'Em atraso', s:'Data de entrega já passada e registro ainda aberto.', f:t=>{const d=dias(t.data);return d!==null&&d<0;}},
hoje :{t:'Com entrega hoje', s:'Precisa sair hoje.', f:t=>dias(t.data)===0},
sete :{t:'Próximos 7 dias', s:'A vencer dentro da semana.', f:t=>{const d=dias(t.data);return d!==null&&d>0&&d<=7;}},
espera :{t:'Aguardando retorno', s:'Dependem de resposta de terceiro — vale reiterar a cobrança.', f:t=>t.status===ESPERA},
semdata:{t:'Sem data definida', s:'Sem data não entram na agenda nem nos alertas de vencimento.', f:t=>!t.data}
}[k];
if(!def) return;
UI.ordem='data-asc';
abrirListaVG(vgEuMesmo()?'Minhas demandas':'Demandas de '+vgNomeAlvo(), def.t, def.s,
abertas.filter(def.f).slice().sort(ordena), 'tarefa', ()=>vgFaixaLista(k));
}
/* =========================================================
MINHAS DEMANDAS — quadros por natureza do registro
========================================================= */
const VG_CAIXAS = [
{id:'prazo', nome:'Prazos', tipos:['prazo'], cor:'#b3261e'},
{id:'audiencia', nome:'Audiências', tipos:['audiencia'], cor:'#1D6FE0'},
{id:'tarefa', nome:'Tarefas', tipos:['tarefa'], cor:'#2d5486'},
{id:'pericias', nome:'Perícias e Avaliações Sociais', tipos:['pericia','avaliacao'], cor:'#6b3fa0'},
{id:'atendimento',nome:'Atendimentos', tipos:['atendimento'], cor:'#127a52'},
{id:'financeiro', nome:'Financeiro', tipos:['financeiro'], cor:'#8a6d1f', so:'fin'},
{id:'comp', nome:'Compromissos pessoais', tipos:null, cor:'#8a5a00'}
];
function vgItensCaixa(id){
const alvo=vgAlvo(), cx=VG_CAIXAS.find(c=>c.id===id);
if(!cx) return [];
if(cx.id==='comp') return vgCompsDe(alvo);
return vgTarefasDe(alvo).filter(t=>cx.tipos.indexOf(t.tipo)>=0);
}
function renderVgCaixas(){
$('vg-caixas').innerHTML = VG_CAIXAS.filter(cx=>{
/* a caixa aparece para quem tem o módulo e também para quem tem
registro financeiro próprio — vazia, some para os dois. */
if(cx.so==='fin') return vgItensCaixa('financeiro').length > 0;
return true;
}).map(cx=>{
const itens = vgItensCaixa(cx.id);
let abertos, conc, atras, hoje;
if(cx.id==='comp'){
/* sai do radar quem já foi concluído à mão ou cuja data já passou */
const fut = itens.filter(c=>{ if(c.concluido) return false;
const d=dias(c.data); return d!==null && d>=0; });
abertos = fut.length;
conc = itens.length-fut.length;
atras = 0;
hoje = itens.filter(c=>!c.concluido && dias(c.data)===0).length;
}else{
const ab = itens.filter(t=>t.status!==CONCLUIDA);
abertos = ab.length;
conc = itens.length-ab.length;
atras = ab.filter(t=>{const d=dias(t.data); return d!==null && d<0;}).length;
hoje = ab.filter(t=>dias(t.data)===0).length;
}
const sete = cx.id==='comp'
? itens.filter(c=>{ if(c.concluido) return false;
const d=dias(c.data); return d!==null&&d>0&&d<=7;}).length
: itens.filter(t=>t.status!==CONCLUIDA).filter(t=>{const d=dias(t.data); return d!==null&&d>0&&d<=7;}).length;
const marcas=[];
if(atras) marcas.push(''+atras+' em atraso');
if(hoje) marcas.push(''+hoje+' hoje');
if(sete) marcas.push(''+sete+' em 7 dias');
if(conc) marcas.push(''+conc+(cx.id==='comp'?' encerrado'+(conc>1?'s':''):' concluído'+(conc>1?'s':''))+'');
if(!marcas.length) marcas.push('nada pendente');
return '';
}).join('');
}
function vgAbrirCaixa(id){
const cx=VG_CAIXAS.find(c=>c.id===id); if(!cx) return;
const quem = vgEuMesmo() ? 'Suas demandas' : 'Demandas de '+vgNomeAlvo();
if(id==='comp'){
const fora = c=>{ if(c.concluido) return 1; const d=dias(c.data); return (d!==null && d<0) ? 1 : 0; };
const itens = vgCompsDe(vgAlvo()).slice().sort((a,b)=>
fora(a)-fora(b) || (fora(a) ? String(b.data).localeCompare(String(a.data))
: String(a.data).localeCompare(String(b.data))));
abrirListaVG(quem, cx.nome, 'A acontecer primeiro. Concluir tira o compromisso do bloco sem apagar nada — a ata, se houver, permanece em Atas e Registros.',
itens, 'comp', ()=>vgAbrirCaixa(id));
return;
}
UI.ordem='data-asc';
const itens = vgItensCaixa(id).slice().sort((a,b)=>{
const ca=a.status===CONCLUIDA?1:0, cb=b.status===CONCLUIDA?1:0;
return ca-cb || ordena(a,b);
});
abrirListaVG(quem, cx.nome, 'Em aberto primeiro, por data de entrega mais próxima.', itens, 'tarefa', ()=>vgAbrirCaixa(id));
}
/* Lista genérica reaproveitando a janela dos indicadores */
function abrirListaVG(kind, titulo, sub, itens, modo, recarregar){
UI.listaModo='vg';
UI.vgLista = recarregar || null;
$('lk-kind').textContent = kind+' · '+itens.length+' registro'+(itens.length===1?'':'s');
$('lk-titulo').textContent = titulo;
$('lk-sub').textContent = sub||'';
/* 'vgmix' = lista que pode trazer demandas e compromissos juntos */
const linha = modo==='comp' ? linhaComp
: modo==='cliente' ? linhaCliente
: modo==='vgmix' ? (x=>(x && x._comp) ? linhaComp(x._comp) : linhaLista(x))
: linhaLista;
$('lk-body').innerHTML = itens.length
? itens.map(linha).join('')
: '
Nada por aqui
Nenhum registro se enquadra neste recorte agora.
';
$('lk-quadro').style.display='none';
abrir('ov-lista');
}
function recarregarLista(){
if(UI.listaModo==='vg'){ if(UI.vgLista) UI.vgLista(); return; }
abrirListaKpi(UI.kpiPainel, UI.kpiAberto);
}
function linhaComp(c){
const oculto = compOculto(c);
const feito = !!c.concluido;
const d=dias(c.data); let cls='';
if(d!==null && !feito){ if(d<0) cls=''; else if(d===0) cls='hoje'; else if(d<=3) cls='perto'; }
const cor = c.tipo==='Externo' ? 'audiencia' : c.tipo==='Pessoal' ? 'avaliacao' : 'tarefa';
/* quem participa pode encerrar pela própria lista, sem abrir o compromisso */
const meu = c.donoId===S.usuario || (c.participantes||[]).indexOf(S.usuario)>=0;
const botao = (meu && !oculto)
? ''
: '';
return '
'+
'
'+esc(c.tipo||'Interno')+''+
(c.particular?'Particular':'')+
(feito?'Concluído':'')+
''+fmtD(c.data)+(c.hora?' '+c.hora:' · sem horário')+'
'+
'
'+esc(oculto?'Ocupado':c.titulo)+'
'+
'
'+esc(oculto?'compromisso particular'
: [c.local, c.obs, (feito?'concluído por '+(c.concluidoPor||'—')+' em '+fmtDH(c.concluido):'')]
.filter(Boolean).join(' · '))+'
'+(vgEuMesmo()?'Concluídos e tempo de execução':'Concluídos e tempo de execução de '+esc(vgPrimeiroNome()))+
''+per.map(p=>'').join('')+'
'+
'
Os quatro números abaixo somam tudo o que já foi concluído em cada janela de tempo — '+
'clique em qualquer um para abrir a lista. O período escolhido no botão acima muda o gráfico e a média destacada.
'+
'
'+kpis+'
'+
'
'+
'
'+
'
Quantos foram concluídos por '+VG_PERIODOS[v.prodPer].toLowerCase()+'
'+
'
'+barras+'
'+
'
Cada barra abrange '+({dia:'um dia',semana:'uma semana',mes:'um mês',ano:'um ano'}[v.prodPer])+
' fechad'+(v.prodPer==='semana'?'a':'o')+', do mais antigo à esquerda para o mais recente à direita.
'+
'
'+
'
'+
'
Quanto tempo levou entre a criação e a conclusão
'+
'
'+
'
Média '+rotPer+''+
''+(tpPer?fmtDur(tpPer.media):'—')+''+
''+(tpPer? tpPer.n+' registro'+(tpPer.n===1?'':'s')+' concluído'+(tpPer.n===1?'':'s')+' no período'
: 'nada concluído neste período')+'
'+
'
Média geral'+(tpTot?fmtDur(tpTot.media):'—')+''+
''+(tpTot? 'em '+tpTot.n+' registro'+(tpTot.n===1?'':'s') : 'ainda sem histórico')+'
'+
'
Mais rápido'+(tpTot?fmtDur(tpTot.min):'—')+''+
'melhor marca
'+
'
Mais demorado'+(tpTot?fmtDur(tpTot.max):'—')+''+
'ponto de atenção
'+
'
Tempo total dedicado'+(tpTot?fmtDur(tpTot.soma):'—')+''+
'soma de todos os registros concluídos
'+
'
'+
'
O contador começa quando o registro é criado — por qualquer pessoa do escritório — '+
'e para quando '+quem+' o registro.
'+
'
'+
'
'+
'
';
}
function vgAbrirConcluidas(p){
UI.ordem='data-desc';
const l = vgNoPeriodo(vgConcluidas(vgAlvo()), p).slice()
.sort((a,b)=>String(b.concluidoEm).localeCompare(String(a.concluidoEm)));
const rot = {dia:'hoje', semana:'nesta semana', mes:'neste mês', ano:'neste ano'}[p];
abrirListaVG((vgEuMesmo()?'Concluídos por mim':'Concluídos por '+vgNomeAlvo()),
'Concluídos '+rot,
l.length ? 'Do mais recente para o mais antigo. Tempo médio: '+fmtDur(vgTempos(l).media)+'.' : '',
l, 'tarefa', ()=>vgAbrirConcluidas(p));
}
/* =========================================================
KANBAN DA MINHA VISÃO
========================================================= */
const VG_GRUPOS = {data:'Data de entrega', tipo:'Tipo de demanda', status:'Status',
prioridade:'Prioridade Alta', sugestao:'Sugestões de Ordem Diária'};
function renderVgBarra(){
const v=vgEstado();
$('vg-bar').innerHTML =
'Agrupar por'+
'
'+ Object.keys(VG_GRUPOS).map(g=>
'').join('')+
'
'+
(v.grupo==='sugestao'
? ''
: '')+
''+
'';
const hint = {
data:'Suas demandas e os seus compromissos separados por quando precisam sair — as vencidas e as de hoje ficam sempre à vista, mesmo vazias. Arraste o quadro para o lado para ver as faixas seguintes.',
tipo:'Uma coluna para cada natureza de registro — prazos, audiências, tarefas, perícias, avaliações sociais, atendimentos e financeiro. Só aparecem os tipos que você tem em aberto. Os compromissos ganham coluna própria no fim.',
status:'Em que estágio cada demanda está. Arraste o cartão de uma coluna para outra para mudar o status sem abrir o registro. Compromisso não tem status, por isso fica na coluna própria, no fim.',
prioridade:'O que foi marcado como prioridade alta vem primeiro, independentemente da data. Compromisso não tem prioridade, por isso fica na coluna própria, no fim.',
sugestao:'Ordem sugerida pelo sistema considerando risco de prazo, hora marcada com terceiros, prioridade e dependência de retorno. Compromisso entra na fila com o peso de quem tem hora combinada com terceiros. Cada cartão traz a posição, o que fazer primeiro e um botão que copia o pedido pronto para o Claude.'
}[v.grupo];
const el=$('vg-hint'); if(el) el.textContent = hint;
}
/* A preferência acompanha o usuário nas próximas aberturas. */
function vgSemVazias(on){
const v=vgEstado(); v.semVazias=!!on;
S.cfg.vgSemVazias=!!on; salvar();
renderVgBarra(); renderVgKanban();
toast(on?'As colunas sem cartão ficam ocultas.':'Todas as colunas voltaram a aparecer.');
}
function vgAbertas(){
return vgTarefasDe(vgAlvo()).filter(t=>t.status!==CONCLUIDA);
}
/* Peso de urgência usado na sugestão de ordem do dia. */
function vgScore(t){
const d=dias(t.data);
let s=0;
if(d===null) s+=10;
else if(d<0) s+=100+Math.min(30,Math.abs(d));
else if(d===0) s+=92;
else if(d===1) s+=74;
else if(d<=3) s+=58;
else if(d<=7) s+=42;
else if(d<=15) s+=26;
else s+=12;
s += ({Alta:26,'Média':10,Baixa:0})[t.prioridade||'Média'] || 10;
/* compromisso tem hora combinada com terceiros: pesa como audiência */
if(t._comp){ return s + 18 + (t.hora?6:0); }
if(t.tipo==='audiencia'||t.tipo==='pericia'||t.tipo==='avaliacao') s+=18; /* hora marcada com terceiros */
if(t.tipo==='prazo') s+=14; /* preclusão não espera */
if(t.hora) s+=6;
if(t.status==='Últimos ajustes') s+=16;
else if(t.status==='Em revisão') s+=12;
else if(t.status==='Em andamento') s+=9;
else if(t.status===ESPERA) s-=26;
const ck = t.checklist && t.checklist.ativo ? (t.checklist.itens||[]) : null;
if(ck && ck.length && ck.filter(i=>i.ok).length/ck.length>=.6) s+=8;
return s;
}
const VG_RECEITA = {
prazo:'Reserve um bloco sem interrupções: leia os autos, monte a tese e deixe a peça revisada antes de protocolar.',
audiencia:'Confirme link ou endereço, revise a tese e fale com o cliente na véspera para alinhar o depoimento.',
tarefa:'Quebre em subtarefas curtas e feche uma de cada vez — comece pela que destrava as demais.',
pericia:'Confirme dia, hora e endereço com o cliente e reforce quais documentos e exames ele deve levar.',
avaliacao:'Alinhe com o cliente o que a assistente social observa e quem precisa estar presente na visita.',
atendimento:'Releia o histórico do cliente antes da conversa e registre os encaminhamentos assim que encerrar.',
financeiro:'Confira valores e comprovantes e faça o lançamento na Gestão Financeira no mesmo dia.'
};
function vgComoFazer(t){
const d=dias(t.data), partes=[];
if(t._comp){
if(d===0) partes.push('É hoje'+(t.hora?' às '+t.hora:'')+' — proteja o horário na agenda.');
else if(d===1) partes.push('É amanhã'+(t.hora?' às '+t.hora:'')+' — confirme hoje.');
partes.push('Compromisso com hora combinada: confirme local e participantes e deixe o bloco livre em volta.');
return partes.join(' ');
}
if(t.status===ESPERA) partes.push('Está aguardando retorno: cobre a resposta hoje e registre em comentário o que foi feito.');
else if(d!==null && d<0) partes.push('Já venceu há '+Math.abs(d)+' dia'+(Math.abs(d)>1?'s':'')+' — trate como primeira tarefa do dia.');
else if(d===0) partes.push('Vence hoje — bloqueie o horário agora.');
else if(d===1) partes.push('Vence amanhã — deixe pronto hoje para revisar com calma.');
partes.push(VG_RECEITA[t.tipo]||'Defina o próximo passo concreto e registre no cartão.');
return partes.join(' ');
}
function vgColunasVisao(lista){
const v=vgEstado();
if(v.grupo==='data'){
const faixa = t=>{ const d=dias(t.data);
if(d===null) return 'sem'; if(d<0) return 'atraso'; if(d===0) return 'hoje';
if(d===1) return 'amanha'; if(d<=7) return 'semana'; if(d<=30) return 'mes'; return 'depois'; };
const defs=[
{id:'atraso',nome:'Em atraso', dot:'#b3261e'},
{id:'hoje', nome:'Entrega hoje', dot:'#e9a23b'},
{id:'amanha',nome:'Amanhã', dot:'#c9862a'},
{id:'semana',nome:'Próximos 7 dias', dot:'#8a6d1f'},
{id:'mes', nome:'Até 30 dias', dot:'#2F6FBF'},
{id:'depois',nome:'Mais adiante', dot:'#2d5486'},
{id:'sem', nome:'Sem data definida',dot:'#9aa2ae'}
];
return defs.map(c=>({id:c.id,nome:c.nome,dot:c.dot,itens:lista.filter(t=>faixa(t)===c.id)}))
.filter(c=>c.itens.length || (!v.semVazias && (c.id==='atraso' || c.id==='hoje')));
}
/* coluna própria dos compromissos, usada nos agrupamentos que dependem
de campos que compromisso não tem (tipo de demanda, status, prioridade) */
const cps = lista.filter(t=>t._comp);
const comComp = cols => cps.length
? cols.concat([{id:VG_COMP_COL.id, nome:VG_COMP_COL.nome, dot:VG_COMP_COL.dot, itens:cps}])
: cols;
if(v.grupo==='tipo'){
return comComp(Object.keys(TIPOS).map(k=>({id:k, nome:TIPOS[k].nome, dot:TIPO_COR[k]||'#2d5486',
itens:lista.filter(t=>t.tipo===k)})).filter(c=>c.itens.length));
}
if(v.grupo==='status'){
return comComp(ABERTOS.map(x=>({id:x, nome:x, dot:STATUS_DOT[x],
itens:lista.filter(t=>!t._comp && t.status===x)})).filter(c=>c.itens.length || !v.semVazias));
}
if(v.grupo==='prioridade'){
const defs=[{id:'Alta',nome:'▲ Prioridade alta',dot:'#b3261e'},
{id:'Média',nome:'Prioridade média',dot:'#c9862a'},
{id:'Baixa',nome:'Prioridade baixa',dot:'#9fc7b4'}];
return comComp(defs.map(c=>({id:c.id,nome:c.nome,dot:c.dot,
itens:lista.filter(t=>!t._comp && (t.prioridade||'Média')===c.id)}))
.filter(c=>c.itens.length || (!v.semVazias && c.id==='Alta')));
}
/* sugestão de ordem diária */
const ord = lista.slice().sort((a,b)=>vgScore(b)-vgScore(a) || ordenaVg(a,b));
const espera=[], agora=[], hoje=[], semana=[], depois=[];
ord.forEach(t=>{
if(t.status===ESPERA){ espera.push(t); return; }
const d=dias(t.data);
if(agora.length<3){ agora.push(t); return; }
if(d!==null && d<=1) hoje.push(t);
else if(d!==null && d<=7) semana.push(t);
else depois.push(t);
});
const cols=[
{id:'agora', nome:'Comece por aqui', dot:'#b3261e', itens:agora},
{id:'hoje', nome:'Ainda hoje', dot:'#e9a23b', itens:hoje},
{id:'semana',nome:'No decorrer da semana', dot:'#8a6d1f', itens:semana},
{id:'depois',nome:'Pode aguardar', dot:'#2d5486', itens:depois},
{id:'espera',nome:'Depende de terceiros', dot:'#E9954A', itens:espera}
];
return cols.filter(c=>c.itens.length || (!v.semVazias && c.id==='agora'));
}
/* =========================================================
COMPROMISSOS DENTRO DO QUADRO DE TRABALHO
Compromisso não tem status nem prioridade: nas faixas de data e na
ordem do dia ele entra junto das demandas; nos agrupamentos que
dependem desses campos, ganha coluna própria. O que a gestão pode ver
continua valendo — compromisso particular aparece como "Ocupado".
========================================================= */
const VG_COMP_COL = {id:'__comp', nome:'Compromissos pessoais', dot:'#8a5a00'};
function compDemanda(c){
return {_comp:c, id:'c'+c.id, tipo:'compromisso', data:c.data, hora:c.hora||'',
descricao:c.titulo||'', prioridade:'Média', status:'Compromisso',
responsaveis:[c.donoId], clientes:[]};
}
/* Só o que ainda vai acontecer — concluído e data passada saem do quadro,
do mesmo jeito que já saem do bloco "Compromissos pessoais". */
function vgCompsQuadro(){
return vgCompsDe(vgAlvo())
.filter(c=>{ if(!c || c.concluido) return false;
const d=dias(c.data); return d===null || d>=0; })
.map(compDemanda);
}
function vgQuadroLista(){ return vgAbertas().concat(vgCompsQuadro()); }
/* ordena() termina em a.id-b.id; o id de compromisso é texto, então o
desempate cai para a comparação por texto em vez de virar NaN. */
function ordenaVg(a,b){
const r = ordena(a,b);
return (r===r) ? r : String(a.id).localeCompare(String(b.id));
}
function cardCompHTML(t, extra){
const c=t._comp, d=dias(c.data), oculto=compOculto(c);
let cls='', ico='';
if(d!==null){ if(d<0){cls='venc'; ico='⚠ ';} else if(d===0){cls='hoje'; ico='● ';} else if(d<=3){cls='perto';} }
const cor = c.tipo==='Externo' ? 'audiencia' : c.tipo==='Pessoal' ? 'avaliacao' : 'tarefa';
const meu = c.donoId===S.usuario || (c.participantes||[]).indexOf(S.usuario)>=0;
const dono = membro(c.donoId);
const dLabel = d===null ? '—' : fmtD(c.data)+(c.hora?' '+c.hora:'')+
(d<0?' · '+Math.abs(d)+'d atrás':d===0?' · hoje':d<=7?' · em '+d+'d':'');
const btnOk = (meu && !oculto)
? '' : '';
return '
'+
(vgEuMesmo()?'Nada em aberto sob sua responsabilidade':'Nada em aberto para '+esc(vgNomeAlvo()))+''+
'Assim que um prazo, audiência, tarefa, perícia, avaliação, atendimento ou compromisso for atribuído, ele aparece aqui.
';
return;
}
/* posição sugerida, calculada uma vez para toda a lista */
const rank = new Map();
lista.slice().sort((a,b)=>vgScore(b)-vgScore(a) || ordenaVg(a,b)).forEach((t,i)=>rank.set(t.id,i+1));
const cols = vgColunasVisao(lista);
el.innerHTML = cols.map(c=>{
const itens = v.grupo==='sugestao' ? c.itens : c.itens.slice().sort(ordenaVg);
const arr = v.grupo==='status'
? ' ondragover="colOver(event)" ondragleave="colLeave(event)" ondrop="vgColDrop(event)"' : '';
return '
';
}).join('');
ajustarAlturaColunas('#pane-visao', 5, '--kcol-h-vg');
}
/* Soltar um cartão em outra coluna do Início muda o status do registro. */
function vgColDrop(e){
e.preventDefault(); e.currentTarget.classList.remove('over');
if(vgEstado().grupo!=='status') return;
const id = dragId || +(e.dataTransfer.getData('text/plain')||0);
if(!id) return; /* compromisso não se arrasta */
const t = S.tarefas.find(x=>x.id===id); if(!t) return;
if(!podeEditar(t)){ toast('Você não tem permissão para alterar este registro.'); return; }
const novo = e.currentTarget.dataset.col;
if(!novo || t.status===novo) return;
aplicarStatus(t.id, novo, false);
}
/* ---------- pedidos prontos para o Claude ---------- */
function vgLinhaPedido(t,i){
const d=dias(t.data);
return (i!=null?(i+1)+'. ':'')+TIPOS[t.tipo].kind+' — '+t.descricao+
' | ação: '+(t.acao||'não definida')+
' | entrega: '+(t.data?fmtD(t.data)+(t.hora?' às '+faixaHora(t):''):'sem data')+
(d!==null && d<0?' (vencido há '+Math.abs(d)+'d)':d===0?' (hoje)':'')+
' | prioridade: '+(t.prioridade||'Média')+
' | status: '+t.status+
' | cliente: '+((t.clientes||[]).map(id=>{const c=cliente(id);return c?c.nome:'';}).filter(Boolean).join(', ')||'não relacionado')+
(t.processo?' | processo: '+t.processo:'');
}
function vgRoteiroDoDia(){
const lista = vgAbertas().slice().sort((a,b)=>vgScore(b)-vgScore(a) || ordena(a,b)).slice(0,12);
if(!lista.length){ toast('Não há registros em aberto para montar o roteiro.'); return; }
const txt =
'Sou da equipe do escritório Schneider e Bahnert Advogados, que atua em Direito do Trabalho (lado do empregado) e Direito Previdenciário. '+
'Abaixo estão as minhas demandas em aberto de hoje ('+fmtD(iso(hoje0()))+'). '+
'Monte uma ordem de execução para o meu dia, explicando em cada item por que ele vem nessa posição, '+
'quanto tempo reservar e qual é o primeiro passo concreto. '+
'Considere risco de preclusão, compromissos com hora marcada e dependências de terceiros. '+
'Ao final, aponte o que dá para delegar ou adiar sem prejuízo.\n\n'+
lista.map((t,i)=>vgLinhaPedido(t,i)).join('\n');
copiar(txt,'Roteiro do dia');
}
function vgPedirClaude(id){
const t=S.tarefas.find(x=>x.id===id); if(!t) return;
const txt =
'Sou da equipe do escritório Schneider e Bahnert Advogados (Trabalhista para o empregado e Previdenciário). '+
'Preciso executar a demanda abaixo. Me diga, em passos objetivos, como conduzi-la do início ao fim, '+
'o que preciso reunir antes de começar, os pontos de atenção técnicos e um rascunho do que devo produzir.\n\n'+
vgLinhaPedido(t,null)+
((t.checklist && t.checklist.ativo && (t.checklist.itens||[]).length)
? '\nSubtarefas já previstas: '+t.checklist.itens.map(i=>(i.ok?'[feito] ':'[pendente] ')+i.txt).join('; ')
: '');
copiar(txt,'Pedido para o Claude');
}
/* =========================================================
GRÁFICOS — desenhados na própria página, sem biblioteca externa
========================================================= */
const TIPO_COR = {prazo:'#C62828', audiencia:'#1D6FE0', tarefa:'#2d5486', pericia:'#6b3fa0',
avaliacao:'#c9862a', atendimento:'#127a52', financeiro:'#8a6d1f'};
const ORIG_COR = {
'Parceria (parceiro operacional)':'#2d5486',
'Parceria (parceiro prospector)':'#6d9ad4',
'Google Ads':'#1D6FE0',
'Meta Ads':'#6b3fa0',
'Google Meu Negócio':'#127a52',
'Indicação':'#EA6A0A',
'Conhecido de sócio':'#B8860B',
'Conhecido de Colab. ou de Assoc.':'#8C3A44',
'Sem origem':'#9aa2ae'
};
const AREA_COR = {'Previdenciário':'#EA6A0A','Trabalhista':'#B8860B','Sucessões':'#8C3A44','Família':'#c1553a',
'Cível':'#6b3fa0','Consumidor':'#0e7490','Criminal':'#3a4a60','Tributário':'#92400e','Empresarial':'#4338ca',
'Administrativo':'#0f6a48','Imobiliário':'#6d28d9','Bancário':'#2b4fc7','Médico e da Saúde':'#9c2b24',
'Outra':'#9aa2ae','Sem área':'#9aa2ae'};
function corArea(a){ return AREA_COR[a] || '#2d5486'; }
/* barras verticais — itens: {rot, n, sel, acao, dica} */
function gBarrasV(itens, opts){
opts = opts || {};
const topo = Math.max.apply(null, itens.map(i=>i.n).concat([1]));
return '
';
}
/* =========================================================
FECHAMENTO DE CONTRATOS
========================================================= */
const UF_NOME = (function(){ const o={}; ESTADOS.forEach(e=>o[e[0]]=e[1]); return o; })();
function ctrEstado(){
const v=vgEstado();
if(v.ctrAno==null){ const h=hoje0(); v.ctrAno=h.getFullYear(); v.ctrMes=h.getMonth(); }
if(!v.ctrEsc) v.ctrEsc='mes';
return v;
}
function ctrSet(campo, valor){ const v=ctrEstado(); v[campo]= (campo==='ctrEsc') ? valor : +valor; renderVgContratos(); }
function contratosTodos(){ return S.clientes.filter(c=>pd(c.dataContrato)); }
function contratosDe(ano, mes){
return contratosTodos().filter(c=>{
const d=pd(c.dataContrato);
return d.getFullYear()===ano && (mes==null || d.getMonth()===mes);
});
}
function ctrAnosDisponiveis(){
const set=new Set(contratosTodos().map(c=>pd(c.dataContrato).getFullYear()));
set.add(hoje0().getFullYear());
return [...set].sort((a,b)=>b-a);
}
function ctrEscopo(){
const v=ctrEstado();
return v.ctrEsc==='ano' ? contratosDe(v.ctrAno, null) : contratosDe(v.ctrAno, v.ctrMes);
}
function ctrRotuloEscopo(){
const v=ctrEstado();
return v.ctrEsc==='ano' ? 'em '+v.ctrAno : 'em '+MESES_NOME[v.ctrMes]+' de '+v.ctrAno;
}
/* UFs que só apareceram a partir do período escolhido */
function ufNovas(lista){
const v=ctrEstado();
const corte = v.ctrEsc==='ano' ? new Date(v.ctrAno,0,1) : new Date(v.ctrAno, v.ctrMes, 1);
const antes = new Set(contratosTodos().filter(c=>pd(c.dataContrato) < corte).map(c=>c.estado).filter(Boolean));
return new Set(lista.map(c=>c.estado).filter(Boolean).filter(uf=>!antes.has(uf)));
}
function ctrLista(titulo, sub, lista){
abrirListaVG('Contratos · '+ctrRotuloEscopo(), titulo, sub,
lista.slice().sort((a,b)=>String(b.dataContrato).localeCompare(String(a.dataContrato))), 'cliente', null);
}
function ctrListaMes(ano, mes){
const l = contratosDe(ano, mes);
abrirListaVG('Contratos', cap1(MESES_NOME[mes])+' de '+ano,
'Contratos com data de assinatura dentro do mês, do mais recente para o mais antigo.',
l.slice().sort((a,b)=>String(b.dataContrato).localeCompare(String(a.dataContrato))), 'cliente',
()=>ctrListaMes(ano,mes));
}
function ctrListaPor(campo, valor){
const lista = ctrEscopo().filter(c=> (c[campo]||'') === (valor==='—'?'':valor) );
const rot = campo==='estado' ? (UF_NOME[valor]||valor) : valor;
ctrLista(rot, 'Contratos fechados '+ctrRotuloEscopo()+'.', lista);
}
/* Cada bloco de número abre a lista completa por trás dele. */
function ctrKpiLista(qual){
const v = ctrEstado();
const recente = (a,b)=>String(b.dataContrato).localeCompare(String(a.dataContrato));
let lista, titulo, sub;
if(qual==='mes'){
lista = contratosDe(v.ctrAno, v.ctrMes).slice().sort(recente);
titulo = 'Contratos fechados em '+cap1(MESES_NOME[v.ctrMes])+' de '+v.ctrAno;
sub = 'Data de assinatura dentro do mês, do mais recente para o mais antigo.';
} else if(qual==='ano'){
lista = contratosDe(v.ctrAno, null).slice().sort(recente);
titulo = 'Contratos fechados em '+v.ctrAno;
sub = 'Todos os contratos assinados no ano, do mais recente para o mais antigo.';
} else if(qual==='uf'){
const novas = ufNovas(ctrEscopo());
lista = ctrEscopo().filter(c=>c.estado).slice()
.sort((a,b)=> String(a.estado).localeCompare(String(b.estado)) || recente(a,b));
const ufs = [...new Set(lista.map(c=>c.estado))];
titulo = 'Estados alcançados '+ctrRotuloEscopo();
sub = ufs.length
? ufs.map(u=>(UF_NOME[u]||u)+(novas.has(u)?' (inédito)':'')).join(' · ')+'.'
: 'Nenhum contrato com estado informado neste período.';
} else {
lista = contratosTodos().slice().sort(recente);
titulo = 'Histórico completo de contratos';
sub = 'Todos os contratos com data registrada desde o início, do mais recente para o mais antigo.';
}
abrirListaVG('Fechamento de contratos', titulo, sub, lista, 'cliente', ()=>ctrKpiLista(qual));
}
function renderVgContratos(){
const v = ctrEstado();
const anos = ctrAnosDisponiveis();
if(anos.indexOf(v.ctrAno)<0) v.ctrAno = anos[0];
/* cabeçalho: mês e ano */
$('ctr-sel').innerHTML =
'Ver'+
''+
''+
'
'+
''+
''+
'
';
const doMes = contratosDe(v.ctrAno, v.ctrMes);
const doAno = contratosDe(v.ctrAno, null);
const antMes = v.ctrMes===0 ? 11 : v.ctrMes-1;
const antAno = v.ctrMes===0 ? v.ctrAno-1 : v.ctrAno;
const anterior = contratosDe(antAno, antMes).length;
const dif = doMes.length - anterior;
const pct = anterior ? Math.round((dif/anterior)*100) : (doMes.length?100:0);
const escopo = ctrEscopo();
const novas = ufNovas(escopo);
const ufs = [...new Set(escopo.map(c=>c.estado).filter(Boolean))];
const hoje = hoje0();
const mesesCorridos = v.ctrAno===hoje.getFullYear() ? hoje.getMonth()+1 : 12;
const media = doAno.length ? doAno.length/mesesCorridos : 0;
const bloco = (cls,chave,k,val,n) =>
'';
$('ctr-kpi').innerHTML =
bloco('destaque','mes','Contratos em '+cap1(MESES_NOME[v.ctrMes]), doMes.length,
[(anterior===0 && !doMes.length ? 'nenhum contrato no mês'
: dif===0 ? 'mesmo número do mês anterior'
: (dif>0?'▲ +':'▼ ')+dif+' em relação a '+MESES_NOME[antMes]+' ('+(dif>0?'+':'')+pct+'%)'),
(dif>0?'sobe':dif<0?'desce':'')])+
bloco('','ano','Total em '+v.ctrAno, doAno.length,
['média de '+media.toFixed(1).replace('.',',')+' por mês'])+
bloco('','uf','Estados alcançados '+(v.ctrEsc==='ano'?'no ano':'no mês'), ufs.length,
[(novas.size? novas.size+' alcançado'+(novas.size>1?'s':'')+' pela primeira vez' : 'nenhum estado inédito')])+
bloco('','tudo','Histórico completo', contratosTodos().length,
['contratos com data registrada desde o início']);
/* --- contratos por mês, no ano escolhido --- */
const barras = MESES_NOME.map((m,i)=>({
rot: m.slice(0,3),
n: contratosDe(v.ctrAno,i).length,
sel: i===v.ctrMes,
acao: 'ctrSet(\'ctrMes\','+i+')',
dica: cap1(m)+' de '+v.ctrAno+': '+contratosDe(v.ctrAno,i).length+' contrato(s) — clique para ver este mês'
}));
$('ctr-mes').innerHTML =
'
Contratos fechados por mês em '+v.ctrAno+
'
'+
gBarrasV(barras)+
'
Clique em uma barra para trocar o mês analisado nos quadros abaixo. '+
'O registro é permanente: qualquer mês já cadastrado continua disponível.
'+(novas.size? 'Em verde, os estados alcançados pela primeira vez pelo escritório. ':'')+
(semUf? semUf+' contrato(s) sem estado informado. ':'')+
'No histórico completo o escritório já atendeu '+
new Set(contratosTodos().map(c=>c.estado).filter(Boolean)).size+' estado(s).
'+(houve
? 'O tempo médio até concluir conta da criação do registro — por qualquer pessoa — até a conclusão pelo responsável.'
: 'Ainda não há conclusões registradas no período escolhido. Troque o período para ver o histórico.')+'
'+
'
';
}
function vgAbrirProdutividade(membroId){
const v=vgEstado();
const l = vgBaseGestao().filter(t=>(t.responsaveis||[]).indexOf(membroId)>=0 &&
t.status===CONCLUIDA && t.concluidoEm && vgPeriodoGestao(t, v.indPer))
.sort((a,b)=>String(b.concluidoEm).localeCompare(String(a.concluidoEm)));
const tp = vgTempos(l);
abrirListaVG(membro(membroId)?membro(membroId).nome:'—', 'Registros concluídos',
(l.length? 'Tempo médio de '+fmtDur(tp.media)+' entre a criação e a conclusão.' : ''),
l, 'tarefa', ()=>vgAbrirProdutividade(membroId));
}
/* ---------- sugestões de melhoria e alertas ---------- */
function vgChipsPorResponsavel(lista, chave){
const mapa = new Map();
lista.forEach(t=>{
const rs = (t.responsaveis||[]);
if(!rs.length){ mapa.set(0,(mapa.get(0)||0)+1); return; }
rs.forEach(id=>mapa.set(id,(mapa.get(id)||0)+1));
});
return [...mapa.entries()].sort((a,b)=>b[1]-a[1]).map(([id,n])=>{
const m = id?membro(id):null;
return '';
}).join('');
}
let VG_ALERTAS = {};
function vgAlertaLista(chave, respId){
const a = VG_ALERTAS[chave]; if(!a) return;
const l = (respId==null ? a.itens : a.itens.filter(t=> respId
? (t.responsaveis||[]).indexOf(respId)>=0
: !(t.responsaveis||[]).length ));
UI.ordem='data-asc';
abrirListaVG('Alerta · '+(respId==null?'todos':(membro(respId)?membro(respId).nome:'Sem responsável')),
a.titulo, a.desc, l.slice().sort(ordena), 'tarefa', ()=>vgAlertaLista(chave, respId));
}
function renderVgAlertas(){
const base = vgBaseGestao();
const abertas = base.filter(t=>t.status!==CONCLUIDA);
VG_ALERTAS = {};
const paradas = abertas.filter(estaParada);
const vencidos = abertas.filter(t=>{const d=dias(t.data); return d!==null && d<0;});
const espera = abertas.filter(t=>{
if(t.status!==ESPERA) return false;
const mov = ultimaMovimentacao(t) || t.alteradoEm || t.criadoEm;
return diasUteisDesde(mov) >= 5;
});
const semResp = abertas.filter(t=>!(t.responsaveis||[]).length);
const semData = abertas.filter(t=>!t.data);
const finPend = (typeof finPendentes==='function') ? finPendentes() : [];
/* carga desequilibrada */
const cargas = atribuiveis().map(m=>({m:m, n:abertas.filter(t=>(t.responsaveis||[]).indexOf(m.id)>=0).length}));
const totalCarga = cargas.reduce((a,b)=>a+b.n,0);
const mediaCarga = cargas.length ? totalCarga/cargas.length : 0;
const sobre = cargas.filter(c=>c.n>=5 && mediaCarga>0 && c.n >= mediaCarga*1.6);
const defs = [
{k:'parada', sev:'a1', ic:'⏳', titulo:'Entrega em até 2 dias úteis e sem movimentação',
itens:paradas,
desc:'Estes registros vencem em até dois dias úteis e não receberam nenhum sinal de trabalho nos últimos dois dias úteis — '+
'nenhum comentário dizendo que está em andamento e nenhuma mudança de status. Vale procurar o responsável antes que vire urgência.'},
{k:'vencido', sev:'a1', ic:'⚠', titulo:'Vencidos e ainda em aberto',
itens:vencidos,
desc:'A data de entrega já passou e o registro continua aberto. Ou o prazo foi cumprido e falta atualizar o status, ou há risco real — vale confirmar caso a caso.'},
{k:'espera', sev:'a2', ic:'⏸', titulo:'Parados em espera há mais de uma semana útil',
itens:espera,
desc:'Aguardar retorno de terceiro é legítimo, mas sem reiteração vira esquecimento. Combine com o responsável uma data para cobrar novamente.'},
{k:'semresp', sev:'a2', ic:'👤', titulo:'Sem responsável definido',
itens:semResp,
desc:'Registro sem dono não entra na rotina de ninguém. Atribua um responsável para que apareça na visão pessoal dele.'},
{k:'semdata', sev:'a3', ic:'📅', titulo:'Sem data de entrega',
itens:semData,
desc:'Sem data, o registro não aparece na agenda nem nos alertas de vencimento. Defina ao menos uma data-meta interna.'},
{k:'fin', sev:'a3', ic:'R$', titulo:'Pendentes de lançamento na Gestão Financeira',
itens:finPend,
desc:'Honorário contratual, RPV, precatório ou outros valores registrados aqui e ainda não baixados no módulo financeiro.'}
].filter(d=>d.itens && d.itens.length);
defs.forEach(d=>{ VG_ALERTAS[d.k] = {titulo:d.titulo, desc:d.desc, itens:d.itens}; });
let html = defs.map(d=>
'
'+
'
'+d.ic+''+esc(d.titulo)+''+d.itens.length+'
'+
'
'+esc(d.desc)+'
'+
'
'+vgChipsPorResponsavel(d.itens, d.k)+'
'+
''+
'
').join('');
if(sobre.length){
html += '
⚖Carga concentrada em poucas pessoas'+
''+sobre.length+'
'+
'
'+esc(sobre.map(c=>c.m.nome.split(/\s+/).slice(0,2).join(' ')+' está com '+c.n+' registros em aberto').join('; '))+
'. A média do escritório é de '+mediaCarga.toFixed(1).replace('.',',')+' por pessoa. '+
'Vale redistribuir o que não depende de conhecimento específico do caso.
';
}
if(!html){
html = '
✓Nenhum alerta no momento
'+
'
Nada vencido, nada parado às vésperas do prazo e todos os registros têm responsável. '+
'Os alertas voltam a aparecer automaticamente assim que algum registro perder o ritmo.
';
}
$('vg-alertas').innerHTML = html;
}
function renderPainel(){
if(PAINEIS_KANBAN[UI.painel]) renderKanban(UI.painel);
if(UI.painel==='visao') renderVisao();
if(UI.painel==='clientes') renderClientes();
if(UI.painel==='financeiro') montarFinanceiro();
if(UI.painel==='rapido') renderRapido();
if(UI.painel==='docs'){ renderDocs(); try{ srvPuxarModelos(); }catch(e){} }
if(UI.painel==='atas') renderAtas();
if(UI.painel==='ia') montarIA();
if(UI.painel==='admin') montarAdmin();
/* a agenda é remontada só quando está à vista — fora dela, o trabalho
seria jogado fora no mesmo instante */
if(UI.painel==='agenda') renderAgenda();
renderAlertasFin();
}
function redesenhar(){ renderHeaderUser(); renderTabs(); renderPainel(); renderRascunhos(); renderSino();
renderPopNotif(); if($('ov-cfg').classList.contains('on')) renderCfg();
/* a lista do módulo aberto em aba separada acompanha */
try{ const w=$('fin-frame').contentWindow; if(w && typeof w.finPendRender==='function') w.finPendRender(); }catch(e){}
}
/* =========================================================
MODAL DE REGISTRO (prazo, audiência, tarefa, perícia,
avaliação social, atendimento, financeiro)
========================================================= */
async function novo(tipo, rascunho){
if(!pode('criar')){ toast('Seu perfil não tem permissão para criar registros.'); return; }
UI.tarefaEdit = {novo:true, tipo:tipo, id:null};
UI.arquivosTmp = [];
montarForm(tipo, null);
$('t-kind').textContent = 'Novo registro · '+TIPOS[tipo].kind;
$('t-titulo').textContent = ({prazo:'Novo prazo',audiencia:'Nova audiência',tarefa:'Nova tarefa',
pericia:'Nova perícia',avaliacao:'Nova avaliação social',atendimento:'Novo atendimento',financeiro:'Novo registro financeiro'})[tipo];
$('t-del').style.display='none';
$('t-cmt-wrap').style.display='';
$('t-cmt-list').innerHTML=''; $('t-cmt-n').textContent=''; montarCaixaComentario();
$('t-cmt-acao').style.display='none'; /* sem registro salvo, o comentário entra junto com o Salvar */
$('t-hist-wrap').style.display='none';
statusMudou();
$('t-criado').value = eu().nome+' · '+fmtDH(agoraISO());
$('t-alt').value = '—';
$('t-link').value=''; checaLinkTarefa(); $('t-files').innerHTML=''; $('t-err').classList.remove('on','aviso');
$('t-salvar').style.display=''; travarForm(false);
if(rascunho){ preencherForm(rascunho); toast('Rascunho recuperado. Revise e salve.'); }
else {
const r=(S.rascunhos||[]).find(x=>x.tipo===tipo && x.autorId===eu().id);
if(r && await confirmar('Há um rascunho de '+TIPOS[tipo].kind.toLowerCase()+' guardado ('+ (r.dados.descricao||'sem descrição') +').\n\nRecuperar esse rascunho?')) preencherForm(r.dados);
}
abrir('ov-tarefa');
}
function abrirTarefa(id){
const t = S.tarefas.find(x=>x.id===id); if(!t) return;
UI.tarefaEdit = {novo:false, tipo:t.tipo, id:id, dataOrig:t.data||'',
criadoEm:t.criadoEm||'', criadoPorId:t.criadoPorId};
UI.arquivosTmp = (t.arquivos||[]).slice();
montarForm(t.tipo, t);
$('t-kind').textContent = TIPOS[t.tipo].kind+' · '+PAINEIS.find(p=>p.k===TIPOS[t.tipo].painel).nome;
$('t-titulo').textContent = t.descricao || TIPOS[t.tipo].nome;
$('t-del').style.display = podeExcluir(t)?'':'none';
$('t-cmt-wrap').style.display=''; $('t-cmt-acao').style.display=''; $('t-hist-wrap').style.display='';
montarCaixaComentario(); statusMudou();
$('t-criado').value = (t.criadoPor||'—')+' · '+fmtDH(t.criadoEm);
$('t-alt').value = t.alteradoEm ? (t.alteradoPor||'—')+' · '+fmtDH(t.alteradoEm) : '—';
$('t-link').value = t.link||''; checaLinkTarefa();
renderArquivos(); renderComentarios(t); renderHistorico(t); renderSerie(t);
const ro = !podeEditar(t);
$('t-salvar').style.display = ro?'none':'';
travarForm(ro);
if(ro) toast('Somente leitura: este registro é de outro integrante.');
$('t-err').classList.remove('on','aviso');
abrir('ov-tarefa');
}
/* ---------- a série de um registro recorrente ----------
Mostra as ocorrências já concluídas, a de agora e a que está agendada, para
ficar visível que concluir o dia de hoje não interrompe nada. */
function renderSerie(t){
const cx = $('t-serie-wrap'); if(!cx) return;
const l = recSerie(t);
if(!recAtiva(t) && l.length < 2){ cx.style.display='none'; return; }
cx.style.display = '';
const r = recDe(t);
const selo = recSelo(t);
const feitas = l.filter(x=>x.status===CONCLUIDA).length;
const proxima = t.recFilha ? S.tarefas.find(x=>x.id===t.recFilha) : null;
const futura = l.find(x=>x.status!==CONCLUIDA && String(x.data||'') > String(t.data||''));
const aberta = proxima || futura;
$('t-serie-n').textContent = l.length+' ocorrência(s) · '+feitas+' concluída(s)';
const linha = x => {
const eu_ = x.id===t.id;
const conc = x.status===CONCLUIDA;
return '';
};
cx.querySelector('#t-serie-box').innerHTML =
'
'+
(recAtiva(t)
? 'Registro '+esc(selo.nome)+'. Ao marcar esta ocorrência como concluída, ela fica guardada com a data de '+
'hoje e a plataforma cria automaticamente a próxima — a recorrência não se perde.'+
(aberta ? ' A próxima já está agendada para '+esc(fmtD(aberta.data))+'.' : '')+
(r.ate ? ' A série vai até '+fmtD(r.ate)+'.' : '')+
(r.vezes ? ' No máximo '+r.vezes+' repetição(ões).' : '')
: 'A recorrência desta série foi encerrada. As ocorrências já criadas continuam como estão.')+
'
'+
'
'+l.map(linha).join('')+'
'+
(recAtiva(t) && podeEditar(t)
? '
'+
(aberta?'':'')+
''+
'
'
: '');
}
/* A caixa de comentário é um editor de texto: negrito, cor, listas, links.
As menções continuam funcionando — a busca do "@" olha a seleção do cursor. */
function montarCaixaComentario(){
const c = $('t-cmt-caixa'); if(!c) return;
c.innerHTML = edHTML('t-cmt-novo', '', 'Escreva um comentário ou registre uma atualização…',
{compacto:true, eventos:'oninput="menTeclado(event)" onkeyup="menTeclado(event)" onblur="setTimeout(menFechar,180)"'});
edFoco($('t-cmt-novo'));
}
function cmtValor(){ return edValor('t-cmt-novo'); }
function cmtTexto(){ return edTexto(edValor('t-cmt-novo')); }
function cmtLimpar(){ const el=$('t-cmt-novo'); if(el){ el.innerHTML=''; edFoco(el); } }
function montarForm(tipo, t){
if(tipo==='financeiro') sincronizarAssociados(true);
const T = TIPOS[tipo];
const clis = S.clientes.slice().sort((a,b)=>a.nome.localeCompare(b.nome));
const selCli = (t&&t.clientes)||[];
const selResp = (t&&t.responsaveis)||[];
const h = [];
h.push(campo('full','Descrição',true,
'','t-desc'));
h.push(campo('full','Cliente',true,
msHTML('t-cli','Digite o nome do cliente…',
'
'+
(clis.length?'':'')+
'
'),
't-cli','Comece a digitar para buscar. Pode selecionar mais de um.'));
h.push(campo('full','Área',false,'',null,
'Vem automaticamente do cadastro do cliente.'));
h.push(campo('full','Processo',false,
'',null,
'Puxa o número do processo do cadastro do cliente, quando houver. Pode ajustar se o cliente tiver mais de um.'));
h.push(campo('',T.dataLabel,true,'','t-data'));
h.push(campo('','Horário de início',false,'',null,
'Opcional. Sem horário, entra na agenda no início do dia.'));
h.push(campo('','Horário de término',false,'',null,
'Opcional. Com início e término, o registro ocupa a faixa certa na agenda.'));
h.push(recHTML(t, tipo));
h.push(campo('full','Motivo da alteração da data',false,
'','t-data-motivo',
'A nova data vai para o histórico de qualquer forma — o motivo explica a mudança para quem consultar depois.'));
h.push(campo('full','Status',true,
'
','t-status',
'Ao marcar Concluída, o registro vai para a visualização de concluídas.'));
h.push(campo('full','Ação a executar',true, msHTML('t-acao','Digite para filtrar as ações…'), 't-acao',
'Comece a digitar e a lista sugere as ações deste tipo de registro.'));
h.push(campo('full','Responsável',true,
msHTML('t-resp','Digite o nome do integrante…'),
't-resp','Comece a digitar para buscar, ou use “Eu mesmo(a)” para assumir o registro. Pode selecionar mais de um.'));
$('t-campos').innerHTML = h.join('');
msInit('t-cli', clis.map(c=>({id:c.id, nome:c.nome, sub:[c.cpf, c.processo, (c.areas||[]).join(' · ')].filter(Boolean).join(' · '),
busca:[c.cpf,c.processo,(c.areas||[]).join(' ')].join(' ')})), selCli);
MS['t-cli'].onChange = syncAreas;
msInit('t-resp', pessoasCampo(), selResp);
msEuAtivar('t-resp');
msInit('t-acao', ACOES[tipo].map((a,i)=>({id:i, nome:a})), [], {unico:true});
if(t && t.acao) msDefinir('t-acao', t.acao);
UI.recFeitas = (t && t.rec && t.rec.feitas) || 0;
if($('t-rec')){ marcarOpcao($('t-rec')); recMudou(); }
dataMudou(); /* o motivo da nova data só aparece quando a data muda */
UI.semCliente = !!(t && t.semCliente);
aplicarSemCliente();
/* campos exclusivos do Financeiro */
if(tipo==='financeiro'){
const f=(t&&t.fin)||{};
$('t-fin').innerHTML = '
Dados financeiros
'+
campo('','Tipo',true,'','t-fin-tipo')+
campo('','Emitir NF',true,'','t-fin-nf')+
campo('full','Advogado parceiro',false,finPartHTML('t-fin-parc-box'),null,
'Pode incluir mais de um. O percentual é a fatia do contrato que cabe a cada parceiro — '+
'se o cliente escolhido já tiver parceiro cadastrado, ele vem preenchido de lá.')+
campo('full','Associado envolvido',false,finPartHTML('t-fin-assoc-box'),null,
'Pode incluir mais de um. Vem dos participantes do escritório registrados no cadastro do cliente.')+
'
';
finPartInit('t-fin-parc-box','PARCEIRO', finListaDe(f,'parceiros','parceiro'));
finPartInit('t-fin-assoc-box','ASSOCIADO', finListaDe(f,'associados','associado'));
finPartRender('t-fin-parc-box'); finPartRender('t-fin-assoc-box');
if(!t) finPartSincCliente(); /* registro novo já nasce preenchido pelo cliente */
} else $('t-fin').innerHTML='';
UI.prioTmp = (t && t.prioridade) || 'Média';
UI.checkTmp = t && t.checklist ? JSON.parse(JSON.stringify(t.checklist)) : {ativo:false, itens:[]};
renderPrio(); renderChecklist(); statusMudou();
UI.procManual = !!(t && t.processo); UI.procAuto = '';
montarTransferencia(t);
syncAreas();
}
function renderPrio(){
const v = UI.prioTmp||'Média';
const dica = {Baixa:'pode esperar a fila normal', 'Média':'segue o fluxo do escritório', Alta:'atenção imediata da equipe'};
$('t-prio').innerHTML = 'Prioridade
'+
PRIOS.map(x=>'').join('')+
'
'+esc(dica[v])+'';
}
function setPrio(v){
UI.prioTmp=v; renderPrio();
const t = UI.tarefaEdit && !UI.tarefaEdit.novo ? S.tarefas.find(x=>x.id===UI.tarefaEdit.id) : null;
if(t && !podeEditar(t)){ UI.prioTmp=t.prioridade||'Média'; renderPrio(); toast('Você não pode alterar este registro.'); }
}
/* ---------- subtarefas ---------- */
function tarefaAtual(){ return UI.tarefaEdit && !UI.tarefaEdit.novo ? S.tarefas.find(x=>x.id===UI.tarefaEdit.id) : null; }
function persistirCheck(txtHist){
const t=tarefaAtual();
if(t){ t.checklist = JSON.parse(JSON.stringify(UI.checkTmp));
if(txtHist){ hist(t,txtHist); t.alteradoEm=agoraISO(); t.alteradoPor=eu().nome; }
salvar(); renderHistorico(t); renderPainel(); }
}
function renderChecklist(){
const c = UI.checkTmp || {ativo:false,itens:[]};
if(!c.ativo){
$('t-check').innerHTML = '
Subtarefas
'+
'
'+
'Quebre este registro em etapas menores quando o trabalho tiver várias fases.'+
'
'+
'';
}
async function ativarCheck(v){
UI.checkTmp = UI.checkTmp||{ativo:false,itens:[]};
if(!v && UI.checkTmp.itens.length && !await confirmar('Desativar remove as '+UI.checkTmp.itens.length+' subtarefas. Continuar?')) return;
UI.checkTmp.ativo=v; if(!v) UI.checkTmp.itens=[];
renderChecklist(); persistirCheck(v?'Checklist de subtarefas ativado.':'Checklist de subtarefas desativado.');
}
function addCheck(){
const el=$('ck-novo'), v=(el.value||'').trim(); if(!v) return;
UI.checkTmp.itens.push({txt:v, ok:false, por:'', em:''});
el.value=''; renderChecklist();
const n=$('ck-novo'); if(n) n.focus();
persistirCheck('Subtarefa adicionada: "'+v+'".');
}
function removerCheck(i){
const it=UI.checkTmp.itens[i]; if(!it) return;
UI.checkTmp.itens.splice(i,1); renderChecklist(); persistirCheck('Subtarefa removida: "'+it.txt+'".');
}
function marcarCheck(i,v){
const it=UI.checkTmp.itens[i]; if(!it) return;
it.ok=v; it.por=v?eu().nome:''; it.em=v?agoraISO():'';
renderChecklist(); persistirCheck(null);
const t=tarefaAtual(); if(t){ salvar(); renderPainel(); }
}
/* ---------- histórico de alterações ---------- */
function renderHistorico(t){
const h = (t && t.historico) || [];
$('t-hist-n').textContent = h.length? '('+h.length+')' : '';
$('t-hist-nota').innerHTML = ehAdmin()
? 'Registro automático de tudo o que muda neste item. Como Administração, você pode remover linhas — os demais perfis só conseguem ler.'
: 'Registro automático de tudo o que muda neste item. Não pode ser editado ou apagado por este perfil.';
$('t-hist-list').innerHTML = h.length
? '
'+h.slice().reverse().map((x,ri)=>{
const i = h.length-1-ri;
return '
'+esc(x.txt)+'
'+
'
'+esc(x.autor)+' · '+fmtDH(x.em)+'
'+
(ehAdmin()?'':'')+
'
';
}).join('')+'
'+
(ehAdmin()?'':'')
: '
Sem alterações registradas.
';
}
async function apagarHist(i){
if(!ehAdmin()){ toast('Somente a Administração altera o histórico.'); return; }
const t=tarefaAtual(); if(!t) return;
if(!await confirmar('Remover esta linha do histórico?')) return;
t.historico.splice(i,1); salvar(); renderHistorico(t);
}
async function limparHist(){
if(!ehAdmin()){ toast('Somente a Administração altera o histórico.'); return; }
const t=tarefaAtual(); if(!t) return;
if(!await confirmar('Apagar todo o histórico deste registro?')) return;
t.historico=[]; salvar(); renderHistorico(t);
}
function montarTransferencia(t){
const box=$('t-transf');
if(!t || !podeEditar(t)){ box.innerHTML=''; return; }
const atuais = nomesResp(t);
box.innerHTML = '
Transferir responsabilidade hoje com '+
(atuais.length? esc(atuais.join(', ')) : 'ninguém')+'
'+
'
'+
campo('','Transferir para',false,'')+
campo('','Motivo',false,
'','t-transf-motivo',
'A troca de responsável vai para o histórico com ou sem motivo — o texto ajuda quem for consultar depois.')+
'
'+
''+
''+
'Transferir substitui o responsável atual; incluir mantém quem já estava. Tudo fica registrado nos comentários.'+
'
'+
'
';
}
function transferir(somar){
const t = S.tarefas.find(x=>x.id===(UI.tarefaEdit&&UI.tarefaEdit.id));
if(!t){ toast('Salve o registro antes de transferir.'); return; }
if(!podeEditar(t)){ toast('Você não pode alterar este registro.'); return; }
const sel=$('t-transf-para'), id=+sel.value;
if(!id){ sel.parentNode.classList.add('err'); toast('Escolha para quem vai a responsabilidade.'); return; }
sel.parentNode.classList.remove('err');
const motivo=($('t-transf-motivo').value||'').trim();
const fMot=document.querySelector('[data-for="t-transf-motivo"]');
if(fMot) fMot.classList.remove('err');
const antes = nomesResp(t);
const novo = membro(id);
t.responsaveis = somar ? (t.responsaveis||[]).concat([id]) : [id];
t.alteradoEm=agoraISO(); t.alteradoPor=eu().nome;
hist(t, (somar?'Incluiu ':'Transferiu para ')+novo.nome+
(somar?' como corresponsável':(antes.length?' (antes: '+antes.join(', ')+')':''))+
(motivo?'. Motivo: '+motivo:'')+'.');
salvar();
/* atualiza a tela do próprio formulário */
if(MS['t-resp']){ MS['t-resp'].sel=(t.responsaveis||[]).slice(); msChips('t-resp'); }
$('t-alt').value = (t.alteradoPor||'—')+' · '+fmtDH(t.alteradoEm);
$('t-transf-motivo').value='';
montarTransferencia(t); renderComentarios(t); renderHistorico(t); redesenhar();
toast(somar? novo.nome+' passou a ser corresponsável.' : 'Registro transferido para '+novo.nome+'.');
}
/* ===== ponte com o cadastro "Advogados parceiros e associados" =====
O módulo financeiro guarda a lista em geral.parceiros, dentro da chave
gf_financeiro_v2. Lemos do armazenamento da própria plataforma e, se
necessário, do documento do módulo — os dois compartilham a mesma origem. */
const FIN_KEY = 'gf_financeiro_v2';
function finLerEstado(){
let raw=null;
try{ raw = localStorage.getItem(FIN_KEY); }catch(e){}
if(!raw){ try{ const w=$('fin-frame').contentWindow; raw = w && w.localStorage && w.localStorage.getItem(FIN_KEY); }catch(e){} }
if(!raw) return null;
try{ const g=JSON.parse(raw); return (g && typeof g==='object') ? g : null; }catch(e){ return null; }
}
function finGravarEstado(g, semPush){
const txt = JSON.stringify(g); let ok=false;
try{ localStorage.setItem(FIN_KEY, txt); ok=true; }catch(e){}
try{ const w=$('fin-frame').contentWindow; if(w && w.localStorage){ w.localStorage.setItem(FIN_KEY, txt); ok=true; } }catch(e){}
if(ok && !semPush && finPodeSincronizar()) srvAgendar('financeiro');
return ok;
}
/* Só a Administração altera a Gestão Financeira — os demais perfis leem do
servidor e nunca enviam nada de volta. */
function finPodeSincronizar(){
if(!SRV.ativo || !SRV.token) return false;
/* trava central: sem ter lido o servidor, esta aba não envia nada */
if(!SRV.leuServidor) return false;
const u=eu(); return !!(u && u.funcao==='Administração');
}
/* chamado de dentro do módulo financeiro sempre que ele grava */
function finSincronizar(){ if(finPodeSincronizar()) srvAgendar('financeiro'); }
/* ---------- da previsão de longo prazo para a fila de lançamento ----------
Quando a Administração marca uma previsão como efetivada, o dinheiro deixou
de ser cenário e virou trabalho: alguém precisa lançar o recebível. A
plataforma abre a tarefa financeira aqui, do lado de fora do quadro, porque
é aqui que moram as tarefas. */
window.finTarefaDePrevisao = function(p){
try{
if(!p) return null;
const desc = 'Lançar recebimento de '+(p.cliente||'cliente')+
(p.valor!=null ? ' — R$ '+Number(p.valor).toLocaleString('pt-BR',{minimumFractionDigits:2}) : '');
/* não duplica: se a tarefa desta previsão já existe e está em aberto, só avisa */
const ja = (S.tarefas||[]).find(t=>t.tipo==='financeiro' && t.previsaoId===p.id && t.status!==CONCLUIDA);
if(ja){ redesenhar(); return ja; }
const t = {
id: novoId('tarefa'), tipo:'financeiro', acao:'Emitir cobrança',
descricao: desc, previsaoId: p.id,
clientes: [], semCliente: true, processo:'',
responsaveis: [S.usuario], prioridade:'Alta', status:'A fazer',
data: p.data && pd(p.data) ? p.data : iso(hoje0()),
obs: 'Previsão de longo prazo marcada como efetivada'+
(p.area?' · área '+p.area:'')+(p.cpf?' · '+p.cpf:'')+
(p.obs?'\n\n'+p.obs:''),
fin: {tipo:'Honorário Contratual', lancado:false, valor:(p.valor!=null?p.valor:null),
origem:'previsao', previsaoId:p.id},
criadoEm: agoraISO(), criadoPor: eu().nome, criadoPorId: eu().id,
comentarios: [], historico: [], arquivos: []
};
hist(t, 'Criada automaticamente: a previsão de longo prazo de '+(p.cliente||'—')+
' foi marcada como efetivada e precisa virar recebível na Gestão Financeira.');
S.tarefas.push(t);
salvar(); redesenhar();
return t;
}catch(e){ return null; }
};
/* Abre o registro financeiro criado a partir de uma previsão. */
window.finAbrirTarefaPrevisao = function(idPrev){
const t = (S.tarefas||[]).find(x=>x.tipo==='financeiro' && x.previsaoId===idPrev);
if(!t){ toast('A tarefa desta previsão não foi encontrada.'); return; }
abrirTarefa(t.id);
};
/* Rede de segurança: o módulo financeiro roda isolado dentro do quadro e nem
sempre consegue avisar a plataforma. Esta vigia percebe qualquer mudança na
base dele e manda ao servidor. */
var FIN_ULT_TXT = null;
function finLeuTexto(){ try{ return localStorage.getItem(FIN_KEY); }catch(e){ return null; } }
function finVigiar(){
clearInterval(SRV._finVig);
FIN_ULT_TXT = finLeuTexto();
SRV._finVig = setInterval(function(){
if(!finPodeSincronizar()) return;
const raw = finLeuTexto();
if(raw==null) return;
if(FIN_ULT_TXT===null){ FIN_ULT_TXT = raw; return; }
if(raw!==FIN_ULT_TXT){ FIN_ULT_TXT = raw; srvAgendar('financeiro');
try{ verificarNotas(); }catch(e){} }
}, 3000);
}
function finListaParceiros(g){
g = g || finLerEstado(); if(!g) return null;
const arr = (g.geral && g.geral.parceiros) || g.parceiros;
return Array.isArray(arr) ? arr : [];
}
function finCadastro(tipo){
const arr = finListaParceiros();
if(arr===null) return null;
return arr.filter(x=>x && x.tipo===tipo)
.sort((a,b)=>String(a.nome||'').localeCompare(String(b.nome||''),'pt-BR'));
}
/* =========================================================
ANEXOS DA PLATAFORMA NO SERVIDOR
Os arquivos anexados a registros, a comentários de cliente e ao
Administrativo eram gravados dentro da base, convertidos em texto. A base
mora no armazenamento do navegador, que tem poucos megabytes: alguns
documentos bastavam para estourar o limite — e então NADA mais é gravado,
nem tarefa, nem cliente, nem marcação. Aqui cada arquivo vira um arquivo
próprio no servidor e a base guarda só o número dele. É a mesma solução já
aplicada na Gestão Financeira.
========================================================= */
function anexoTipoDataUrl(u){
const t=String(u||''), v=t.indexOf(',');
if(!/^data:/i.test(t) || v<0) return 'application/octet-stream';
return (t.slice(5,v).split(';')[0]||'').trim() || 'application/octet-stream';
}
function anexoB64DataUrl(u){
const t=String(u||''), v=t.indexOf(',');
if(v<0) return '';
const cab=t.slice(5,v), corpo=t.slice(v+1);
if(/(^|;)\s*base64\s*$/i.test(cab)) return corpo.replace(/\s/g,'');
return btoa(unescape(encodeURIComponent(decodeURIComponent(corpo))));
}
function anexoTemServidor(){ return !!(SRV.ativo && SRV.token); }
/* Sobe um anexo e devolve a referência. Sem servidor, devolve null e o
arquivo segue como está — nada deixa de funcionar. */
async function anexoSubir(nome, dataUrl){
if(!anexoTemServidor() || !dataUrl) return null;
try{
const j = await srvChamar('anexo-subir', {nome:nome||'anexo',
tipo:anexoTipoDataUrl(dataUrl), dados:anexoB64DataUrl(dataUrl)});
return (j && j.ok && j.id) ? j : null;
}catch(e){ return null; }
}
/* Percorre uma lista de anexos e sobe o que ainda estiver como texto. */
async function anexosSubirLista(L){
const r = {subiram:0, ficaram:0};
for(const a of (L||[])){
if(!a || a.ref || !a.dataUrl) continue;
const j = await anexoSubir(a.nome, a.dataUrl);
if(j){ a.ref=j.id; a.bytes=j.bytes|0; delete a.dataUrl; r.subiram++; }
else r.ficaram++;
}
return r;
}
async function anexoDataUrl(ref){
const j = await srvChamar('anexo-ler', {id:ref});
if(!j || !j.ok || !j.dados) throw new Error((j&&j.msg)||'anexo indisponível');
return 'data:'+(j.tipo||'application/octet-stream')+';base64,'+j.dados;
}
/* Baixa o anexo, venha ele da base (arquivos antigos) ou do servidor. */
async function anexoAbrir(nome, dataUrl, ref){
let url = dataUrl||'';
if(!url && ref){
try{ url = await anexoDataUrl(ref); }
catch(e){ toast('Não consegui trazer "'+(nome||'o anexo')+'" do servidor.'); return; }
}
if(!url){ toast('Este arquivo não está guardado no sistema.'); return; }
try{ baixarDataUrl(nome||'anexo', url); }
catch(e){ toast('Não consegui preparar o arquivo.'); }
}
/* Um endereço de dados vira arquivo salvo, sem passar por fetch — a política
de segurança da plataforma barra a busca de endereços data:. */
function baixarDataUrl(nome, dataUrl){
const b = dataUrlParaBlob(dataUrl);
if(!b) throw new Error('formato');
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u; a.download = nome || 'anexo';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>{ try{ URL.revokeObjectURL(u); }catch(e){} }, 60000);
}
/* Botão único para qualquer anexo, esteja ele na base ou no servidor. */
function anexoBotaoHTML(a, ctx, i){
if(!a) return '';
if(a.dataUrl) return 'baixar';
if(a.ref) return '';
return '—';
}
/* Liga os botões de anexo dentro de um trecho recém-desenhado. */
function anexoLigar(escopo){
(escopo||document).querySelectorAll('[data-anexoref]').forEach(b=>{
b.onclick = e=>{ e.preventDefault(); e.stopPropagation();
anexoAbrir(b.dataset.anexonome, '', b.dataset.anexoref); };
});
}
/* =========================================================
ESPAÇO DA GESTÃO FINANCEIRA
A base financeira mora no armazenamento do navegador, que tem poucos
megabytes. Os PDFs anexados aos lançamentos eram gravados dentro dela,
convertidos em texto: bastavam alguns boletos para estourar o limite e a
gravação falhar inteira. Aqui se vê quanto está ocupado, e move-se o que
está preso lá dentro para o servidor.
========================================================= */
function bytesLegivel(n){
n = +n || 0;
if(n>=1048576) return (n/1048576).toFixed(1).replace('.',',')+' MB';
if(n>=1024) return Math.round(n/1024)+' KB';
return n+' bytes';
}
/* Quanto a base ocupa e quanto disso é anexo preso dentro dela. */
function finEspaco(){
let texto=''; try{ texto = localStorage.getItem(FIN_KEY)||''; }catch(e){}
const total = texto.length;
const g = finLerEstado();
let presos=0, bytesPresos=0, noServidor=0;
if(g){
(g.recebiveis||[]).concat(g.finalizados||[]).forEach(r=>{
(r.anexos||[]).forEach(a=>{
if(a && a.ref) noServidor++;
else if(a && a.url){ presos++; bytesPresos += String(a.url).length; }
});
});
(g.despesas||[]).forEach(d=>{
if(d && d.anexoUrl && /^data:/i.test(String(d.anexoUrl))){ presos++; bytesPresos += String(d.anexoUrl).length; }
});
}
return {total, presos, bytesPresos, noServidor, temBase:!!g};
}
/* Quanto a base da plataforma ocupa e quanto disso é anexo preso dentro dela. */
function espacoPlataforma(){
let texto=''; try{ texto = localStorage.getItem(KEY)||''; }catch(e){}
let presos=0, bytesPresos=0, noServidor=0;
const conta = L => (L||[]).forEach(a=>{
if(!a) return;
if(a.ref) noServidor++;
else if(a.dataUrl){ presos++; bytesPresos += String(a.dataUrl).length; }
else if(a.dados){ presos++; bytesPresos += String(a.dados).length; }
});
(S.tarefas||[]).forEach(t=>conta(t.arquivos));
(S.clientes||[]).forEach(c=>{
conta(c.arquivos);
(c.comentarios||[]).forEach(x=>conta(x.arquivos));
});
/* Administrativo: documentos da sociedade, treinamentos e os registros de
acompanhamento de cada pessoa. */
(S.admDocs||[]).forEach(d=>conta(d.anexos));
(S.admTreinos||[]).forEach(d=>conta(d.anexos));
(S.admPessoas||[]).forEach(pes=>{ (pes.registros||[]).forEach(r=>conta(r.anexos)); });
return {total: texto.length, presos, bytesPresos, noServidor};
}
function cfgEspacoPlataforma(){
const e = espacoPlataforma();
const limite = 5*1024*1024;
const pct = Math.min(100, Math.round(e.total/limite*100));
const apertado = pct >= 70;
return '
Espaço da plataforma
'+
'
A base da plataforma — equipe, clientes, registros, agenda — também fica guardada no '+
'navegador, com cerca de 5 MB reservados. Quando esse espaço acaba, nada mais é gravado: nem '+
'tarefa, nem cliente, nem marcação. Os arquivos anexados são o que mais ocupa.
'+
'
'+
'
Base da plataforma
'+bytesLegivel(e.total)+'
'+
'
cerca de '+pct+'% do espaço disponível
'+
'
Anexos dentro da base
'+e.presos+'
'+
'
'+(e.presos?bytesLegivel(e.bytesPresos)+' ocupados por eles':'nenhum — é o certo')+'
'+
'
Anexos no servidor
'+e.noServidor+'
'+
'
não ocupam espaço do navegador
'+
'
'+
(e.presos
? '
'+
'
'+
'
Os arquivos continuam acessíveis do mesmo jeito, pelo botão baixar em cada '+
'anexo — só deixam de ocupar o espaço do navegador'+
(SRV.ativo?'.':'. Sem servidor no ar não há para onde movê-los.')+'
'
: '
Nada preso dentro da base. Novos anexos já vão direto para o servidor.
');
}
/* Move para o servidor todos os anexos gravados dentro da base da plataforma. */
async function migrarAnexosPlataforma(){
if(!anexoTemServidor()){ toast('É preciso estar conectado ao servidor para mover os anexos.'); return; }
const alvo = [];
const juntar = L => (L||[]).forEach(a=>{
if(!a || a.ref) return;
if(a.dataUrl) alvo.push({a:a, campo:'dataUrl'});
else if(a.dados) alvo.push({a:a, campo:'dados'});
});
(S.tarefas||[]).forEach(t=>juntar(t.arquivos));
(S.clientes||[]).forEach(c=>{ juntar(c.arquivos); (c.comentarios||[]).forEach(x=>juntar(x.arquivos)); });
(S.admDocs||[]).forEach(d=>juntar(d.anexos));
(S.admTreinos||[]).forEach(d=>juntar(d.anexos));
(S.admPessoas||[]).forEach(pes=>{ (pes.registros||[]).forEach(r=>juntar(r.anexos)); });
if(!alvo.length){ toast('Não há anexos presos dentro da base.'); return; }
if(!await confirmar('Mover '+alvo.length+' anexo(s) para o servidor?\n\n'+
'Os arquivos continuam acessíveis pelo botão baixar, em cada anexo. O que muda é que deixam de ocupar '+
'a memória do navegador, que é o que faz a gravação falhar.')) return;
let feitos=0, falhas=0, liberados=0;
for(const alv of alvo){
const url = alv.a[alv.campo];
const j = await anexoSubir(alv.a.nome, url);
if(j){ liberados += String(url).length; alv.a.ref=j.id; alv.a.bytes=j.bytes|0;
delete alv.a[alv.campo]; feitos++; }
else falhas++;
}
/* salvarAgora sozinho não grava: ele só descarrega o que salvar() deixou
pendente. Como a migração mexe na base direto, é salvar() quem marca. */
if(feitos){ salvar(); salvarAgora(); redesenhar(); }
renderCfg();
toast(feitos+' anexo(s) movidos para o servidor · '+bytesLegivel(liberados)+' liberados no navegador'+
(falhas? ' · '+falhas+' não subiram.' : '.'));
}
function cfgEspacoFin(){
const e = finEspaco();
if(!e.temBase) return '';
const limite = 5*1024*1024; /* o teto usual dos navegadores */
const pct = Math.min(100, Math.round(e.total/limite*100));
const apertado = pct >= 70;
return '
Espaço da Gestão Financeira
'+
'
A base financeira fica guardada no navegador, que reserva cerca de 5 MB para ela. '+
'Quando esse espaço acaba, a gravação falha inteira — não é o anexo que não cabe, é a base que não '+
'consegue mais ser escrita.
'+
'
'+
'
Base financeira
'+bytesLegivel(e.total)+'
'+
'
cerca de '+pct+'% do espaço disponível
'+
'
Anexos dentro da base
'+e.presos+'
'+
'
'+(e.presos?bytesLegivel(e.bytesPresos)+' ocupados por eles':'nenhum — é o certo')+'
'+
'
Anexos no servidor
'+e.noServidor+'
'+
'
não ocupam espaço do navegador
'+
'
'+
(e.presos
? '
'+
'
'+
'
Os arquivos continuam acessíveis do mesmo jeito, pelo botão Ver dentro de cada '+
'registro — só deixam de ocupar o espaço do navegador'+
(SRV.ativo?'.':' . Sem servidor no ar não há para onde movê-los.')+'
'
: '
Nada preso dentro da base. Novos anexos já vão direto para o servidor.
');
}
/* Move para o servidor todos os anexos gravados dentro da base. */
async function finMigrarAnexos(){
if(!SRV.ativo || !SRV.token){ toast('É preciso estar conectado ao servidor para mover os anexos.'); return; }
const g = finLerEstado();
if(!g){ toast('Abra a Gestão Financeira uma vez antes de mover os anexos.'); return; }
const alvo = [];
(g.recebiveis||[]).concat(g.finalizados||[]).forEach(r=>{
(r.anexos||[]).forEach(a=>{ if(a && !a.ref && a.url && /^data:/i.test(a.url)) alvo.push({a:a, nome:a.nome||'anexo.pdf'}); });
});
(g.despesas||[]).forEach(d=>{
if(d && d.anexoUrl && /^data:/i.test(String(d.anexoUrl)))
alvo.push({d:d, nome:d.anexoNome||'anexo.pdf'});
});
if(!alvo.length){ toast('Não há anexos presos dentro da base.'); return; }
if(!await confirmar('Mover '+alvo.length+' anexo(s) para o servidor?\n\n'+
'Os arquivos continuam acessíveis pelo botão Ver dentro de cada registro. '+
'O que muda é que deixam de ocupar a memória do navegador, que é o que faz a gravação falhar.')) return;
let feitos=0, falhas=0, liberados=0, motivo='';
for(const alv of alvo){
const url = alv.a ? alv.a.url : alv.d.anexoUrl;
try{
const j = await srvChamar('anexo-subir', {nome:alv.nome, tipo:_tipoDataUrl(url), dados:_b64DataUrl(url)});
if(j && j.ok && j.id){
liberados += String(url).length;
if(alv.a){ alv.a.ref=j.id; alv.a.bytes=j.bytes|0; alv.a.url=''; }
else { alv.d.anexoRef=j.id; alv.d.anexoBytes=j.bytes|0; alv.d.anexoUrl=''; alv.d.anexoTipo='servidor'; }
feitos++;
} else { falhas++; motivo=(j&&j.msg)||''; }
}catch(e){ falhas++; motivo='sem conexão'; }
}
if(feitos){
finGravarEstado(g);
SOMBRA_FIN = _mapa(g, COLS_FIN); FIN_ULT_TXT = finLeuTexto();
if(UI.painel==='financeiro'){ if(!finAtualizarFrame()) montarFinanceiro(true); } else FIN_PRONTO=false;
}
renderCfg();
toast(feitos+' anexo(s) movidos para o servidor · '+bytesLegivel(liberados)+' liberados no navegador'+
(falhas? ' · '+falhas+' não subiram'+(motivo?' ('+motivo+')':'') : '.'));
}
function _tipoDataUrl(u){
const t=String(u||''), v=t.indexOf(',');
if(!/^data:/i.test(t) || v<0) return 'application/octet-stream';
return (t.slice(5,v).split(';')[0]||'').trim() || 'application/octet-stream';
}
function _b64DataUrl(u){
const t=String(u||''), v=t.indexOf(',');
if(v<0) return '';
const cab=t.slice(5,v), corpo=t.slice(v+1);
if(/(^|;)\s*base64\s*$/i.test(cab)) return corpo.replace(/\s/g,'');
return btoa(unescape(encodeURIComponent(decodeURIComponent(corpo))));
}
/* =========================================================
CÓPIAS GUARDADAS NO SERVIDOR
O servidor já guardava a base anterior a cada gravação, mas essas cópias
só eram alcançáveis pelo gerenciador de arquivos da hospedagem — na
prática, não existiam para quem usa a plataforma. Um lançamento perdido
não tinha caminho de volta pela tela. Agora tem, e com duas saídas: trazer
apenas o que está faltando, sem desfazer nada do que veio depois, ou
restaurar a cópia inteira.
========================================================= */
var FC_LISTA = null;
async function finCopiasAbrir(){
if(!SRV.ativo || !SRV.token){ toast('As cópias ficam no servidor — é preciso estar conectado a ele.'); return; }
abrir('ov-fincopias');
$('fc-corpo').innerHTML = '
';
}
}
function fcConta(c){
if(!c) return '—';
return ''+(c.registros!=null?c.registros:'—')+' contratos · '+
(c.baixas!=null?c.baixas:'—')+' parcelas baixadas · '+
(c.recebiveis!=null?c.recebiveis:'—')+' em aberto · '+
(c.finalizados!=null?c.finalizados:'—')+' finalizados · '+
(c.despesas!=null?c.despesas:'—')+' despesas';
}
function finCopiasRender(){
const b = FC_LISTA;
if(!b){ $('fc-corpo').innerHTML='
Nada a mostrar.
'; return; }
const at = b.atual||{}, copias = b.copias||[];
let h = '
Base de agora
'+
fcConta(at.conta)+' versão '+(at.versao|0)+
(at.em?' · gravada em '+esc(fmtDH(at.em)):'')+(at.por?' · por '+esc(at.por):'')+'
';
if(!copias.length){
h += '
Ainda não há cópias guardadas. A primeira aparece na próxima vez que a base for substituída.
';
$('fc-corpo').innerHTML=h; return;
}
h += '
Cópias, da mais recente para a mais antiga
';
h += copias.map(function(c){
const cc = c.conta||{}, ac = at.conta||{};
const dif = [];
['registros','baixas'].forEach(function(k){
const a=+(ac[k]||0), v=+(cc[k]||0);
if(v>a) dif.push('tem '+(v-a)+' '+(k==='registros'?'contrato(s)':'parcela(s) baixada(s))')+' que a base de agora não tem');
});
return '
'+
'
'+
'
'+esc(fmtDH(c.gravadaEm||c.em))+
' · versão '+(c.versao|0)+'
'+
'
'+fcConta(cc)+'
'+
(dif.length? '
'+dif.join(' · ')+'
' : '')+
'
'+
''+
''+
'
';
}).join('');
h += '
Trazer o que falta acrescenta à base de agora os registros que existem na cópia e '+
'não existem mais aqui. Nada do que foi feito depois é desfeito. '+
'Restaurar inteira troca a base de agora pela da cópia — o que veio depois dela se perde.
';
$('fc-corpo').innerHTML = h;
}
async function fcLer(arquivo){
const j = await srvChamar('versoes-ler', {base:'financeiro', arquivo:arquivo});
if(!j.ok || !j.dados || !j.dados.recebiveis) throw new Error(j.msg||'cópia ilegível');
return j.dados;
}
async function finCopiaTrazer(arquivo){
let copia=null;
try{ copia = await fcLer(arquivo); }catch(e){ toast('Não consegui ler esta cópia.'); return; }
const agora = finLerEstado();
if(!agora){ toast('Abra a Gestão Financeira uma vez antes de recuperar.'); return; }
const faltando = _faltandoNaAtual(agora, copia);
if(!faltando.total){ toast('Esta cópia não tem nada que esteja faltando na base de agora.'); return; }
const resumo = faltando.linhas.slice(0,12).map(x=>'· '+x).join('\n')+
(faltando.linhas.length>12 ? '\n· … e mais '+(faltando.linhas.length-12) : '');
if(!await confirmar('Trazer de volta '+faltando.total+' registro(s) que existem nesta cópia e não existem mais na base de agora?\n\n'+
resumo+'\n\nNada do que foi feito depois é desfeito.')) return;
const juntos = finRemotoMaisLocaisSoDaqui(agora, copia); /* agora + o que só a cópia tem */
finGravarEstado(juntos, true);
SOMBRA_FIN = _mapa(juntos, COLS_FIN); FIN_ULT_TXT = finLeuTexto();
if(UI.painel==='financeiro'){ if(!finAtualizarFrame()) montarFinanceiro(true); } else FIN_PRONTO=false;
if(finPodeSincronizar()) srvAgendar('financeiro', true);
fechar('ov-fincopias');
toast(faltando.total+' registro(s) recuperado(s) da cópia de '+fmtDH(arquivoData(arquivo))+'.');
}
async function finCopiaRestaurar(arquivo){
let copia=null;
try{ copia = await fcLer(arquivo); }catch(e){ toast('Não consegui ler esta cópia.'); return; }
const agora = finLerEstado()||{};
const a = (agora.recebiveis||[]).length+(agora.finalizados||[]).length;
const v = (copia.recebiveis||[]).length+(copia.finalizados||[]).length;
if(!await confirmar('Substituir a base da Gestão Financeira pela cópia de '+fmtDH(arquivoData(arquivo))+'?\n\n'+
'Agora: '+a+' contratos. Na cópia: '+v+' contratos.\n\n'+
'Tudo o que foi lançado depois dessa cópia se perde. Se a intenção é recuperar um lançamento que '+
'desapareceu, use "Trazer o que falta" — ela não desfaz nada.')) return;
finGravarEstado(copia, true);
SOMBRA_FIN = _mapa(copia, COLS_FIN); FIN_ULT_TXT = finLeuTexto();
if(UI.painel==='financeiro'){ if(!finAtualizarFrame()) montarFinanceiro(true); } else FIN_PRONTO=false;
if(finPodeSincronizar()) srvAgendar('financeiro', true);
fechar('ov-fincopias');
toast('Base da Gestão Financeira restaurada pela cópia de '+fmtDH(arquivoData(arquivo))+'.');
}
/* financeiro-20260928-141459-ac47b6.dat.php -> data legível */
function arquivoData(arquivo){
const m = String(arquivo||'').match(/-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-/);
if(!m) return agoraISO();
return m[1]+'-'+m[2]+'-'+m[3]+'T'+m[4]+':'+m[5]+':'+m[6]+'Z';
}
/* O que a cópia tem e a base de agora não tem mais. */
function _faltandoNaAtual(agora, copia){
const grupos = [['recebiveis','finalizados'], ['despesas'], ['previsoes'], ['nfHistorico']];
const linhas = []; let total = 0;
grupos.forEach(g=>{
const ids={};
g.forEach(c=>{ ((agora&&agora[c])||[]).forEach(r=>{ if(r&&r.id!=null) ids[String(r.id)]=1; }); });
g.forEach(c=>{
((copia&&copia[c])||[]).forEach(r=>{
if(!r || r.id==null || ids[String(r.id)]) return;
total++;
linhas.push((r.cliente||r.descricao||r.tipo||('registro '+r.id))+
(r.devido!=null?' — '+brlSimples(r.devido):'')+' (nº '+r.id+')');
});
});
});
return {total:total, linhas:linhas};
}
function brlSimples(v){
const n=+v; if(isNaN(n)) return '';
return 'R$ '+n.toLocaleString('pt-BR',{minimumFractionDigits:2, maximumFractionDigits:2});
}
/* ===== espelho dos associados: equipe <-> Gestão Financeira ===== */
const FUNCAO_ASSOCIADO = 'Advogado(a) associado(a)';
const chaveNome = n => String(n||'').trim().toLowerCase().replace(/\s+/g,' ');
function sincronizarAssociados(silencioso){
const g = finLerEstado();
if(!g) return {lidos:0, enviados:0, importados:0};
g.geral = g.geral || {};
if(!Array.isArray(g.geral.parceiros)) g.geral.parceiros = Array.isArray(g.parceiros)? g.parceiros : [];
const lista = g.geral.parceiros;
let enviados=0, importados=0;
/* equipe -> financeiro */
const proxId = () => Math.max(0, ...lista.map(x=>+x.id||0)) + 1;
S.membros.filter(m=>m.funcao===FUNCAO_ASSOCIADO && m.status!=='inativo').forEach(m=>{
const jaTem = lista.some(x=>chaveNome(x.nome)===chaveNome(m.nome));
if(!jaTem){
lista.push({id:proxId(), nome:m.nome, tipo:'ASSOCIADO', area:'Outra',
cpf:m.cpf||'', tel:m.tel||'', pix:m.pix||'', obs:'Cadastrado pela equipe da plataforma.'});
enviados++;
}
});
/* financeiro -> equipe */
lista.filter(x=>x && x.tipo==='ASSOCIADO' && x.nome).forEach(x=>{
const jaTem = S.membros.some(m=>chaveNome(m.nome)===chaveNome(x.nome));
if(!jaTem){
S.membros.push({id:novoId('membro'), nome:x.nome, funcao:FUNCAO_ASSOCIADO,
email:'', tel:x.tel||'', cpf:x.cpf||'', pix:x.pix||'', oab:'', uf:'', email2:'', tel2:'', foto:'',
status:'pendente', escopo:'meu', perms:Object.assign({}, FUNCOES[FUNCAO_ASSOCIADO].perms),
agendaVisivel:'detalhes', agendaParticular:false, viaFinanceiro:true, criadoEm:agoraISO()});
importados++;
}
});
if(enviados || importados){
finGravarEstado(g); salvar();
if(FIN_PRONTO && UI.painel!=='financeiro') montarFinanceiro(true);
if(!silencioso){
const partes=[];
if(enviados) partes.push(enviados+' associado(s) enviado(s) para a Gestão Financeira');
if(importados) partes.push(importados+' associado(s) trazido(s) para a equipe');
toast(partes.join(' · ')+'.');
}
}
return {lidos:lista.length, enviados:enviados, importados:importados};
}
function selParceiro(id,tipo,val,vazio){
const lista = finCadastro(tipo);
const atual = val && typeof val==='object' ? val : (val?{id:null,nome:String(val)}:null);
if(lista===null)
return '';
if(!lista.length)
return '';
const jaTem = atual && lista.some(x=>x.nome===atual.nome);
return '';
}
/* =========================================================
ADVOGADOS PARCEIROS E ASSOCIADOS COM PERCENTUAL — tarefas financeiras
Cada campo aceita mais de um nome, cada nome com o seu percentual. Quando
a tarefa aponta para um único cliente que já tem parceiros cadastrados, os
nomes e os percentuais vêm preenchidos de lá — sem tirar da usuária a
liberdade de mexer.
========================================================= */
const FINP = {};
/* aceita tanto o formato novo (lista) quanto o antigo (um único objeto) */
function finListaDe(f, chaveNova, chaveVelha){
if(!f) return [];
const nova = f[chaveNova];
if(Array.isArray(nova)) return nova;
const v = f[chaveVelha];
if(!v) return [];
return [ typeof v==='object' ? {id:v.id!=null?v.id:null, nome:v.nome||'', pct:v.pct!=null?v.pct:''}
: {id:null, nome:String(v), pct:''} ];
}
function finPartInit(campoId, tipo, valores){
FINP[campoId] = {tipo:tipo, itens:(valores||[]).filter(Boolean).map(v=>({
id:(v && v.id!=null)?v.id:null, nome:(v && v.nome)||String(v||''), pct:(v && v.pct!=null)?v.pct:''}))};
}
function finPartHTML(campoId){ return ''; }
function finPartSoma(campoId){
const st=FINP[campoId]; if(!st) return 0;
return st.itens.reduce((a,x)=> a + (parseFloat(x.pct)||0), 0);
}
function finPartRender(campoId){
const st=FINP[campoId], box=$(campoId); if(!st||!box) return;
const lista = finCadastro(st.tipo);
if(lista===null){
box.innerHTML='
Abra a Gestão Financeira uma vez para carregar o cadastro de parceiros.
';
}
function finPartAdd(campoId){
const st=FINP[campoId]; if(!st) return;
st.itens.push({id:null, nome:'', pct:''});
finPartRender(campoId);
}
function finPartTirar(campoId,i){
const st=FINP[campoId]; if(!st) return;
st.itens.splice(i,1); finPartRender(campoId);
}
function finPartNome(campoId,i,el){
const st=FINP[campoId]; if(!st||!st.itens[i]) return;
const op = el.options[el.selectedIndex];
st.itens[i].nome = el.value||'';
st.itens[i].id = (op && op.dataset.fid) ? +op.dataset.fid : null;
}
function finPartPct(campoId,i,v){
const st=FINP[campoId]; if(!st||!st.itens[i]) return;
const t = String(v==null?'':v).replace(',','.');
st.itens[i].pct = t==='' ? '' : Math.max(0, Math.min(100, parseFloat(t)||0));
/* só o somatório é redesenhado, para não tirar o foco de quem digita */
const box=$(campoId); if(!box) return;
const soma=finPartSoma(campoId), sp=box.querySelector('.fparc-ft .soma');
if(sp){ sp.classList.toggle('alto', soma>100); sp.innerHTML='Repasse somado: '+pctFmt(soma)+''; }
}
function finPartValor(campoId){
const st=FINP[campoId]; if(!st) return [];
return st.itens.filter(x=>x.nome)
.map(x=>({id:x.id, nome:x.nome, pct:(x.pct===''||x.pct==null)?null:+x.pct}));
}
/* ---- ponte com o cadastro do cliente ---- */
function finPartTemCliente(){
const ids = (typeof msSel==='function' && MS['t-cli']) ? msSel('t-cli') : [];
return ids.length===1 && !!cliente(ids[0]);
}
function finPartDoCliente(){
const ids = MS['t-cli'] ? msSel('t-cli') : [];
if(ids.length!==1) return null;
const c = cliente(ids[0]); if(!c) return null;
const parc = (c.parceiros||[]).filter(x=>x && x.nome)
.map(x=>({id:x.id!=null?x.id:null, nome:x.nome, pct:(x.pct==null?'':x.pct)}));
const cad = finCadastro('ASSOCIADO')||[];
const assoc = (c.participantesPct||[]).filter(x=>x && x.nome).map(x=>{
const y = cad.find(z=>chaveNome(z.nome)===chaveNome(x.nome));
return y ? {id:y.id, nome:y.nome, pct:(x.pct==null?'':x.pct)} : null;
}).filter(Boolean);
return {parceiros:parc, associados:assoc, cliente:c};
}
function finPartPuxar(campoId, avisar){
const d = finPartDoCliente();
if(!d){ if(avisar) toast('Selecione um único cliente para puxar os percentuais.'); return false; }
const st = FINP[campoId]; if(!st) return false;
const fonte = st.tipo==='PARCEIRO' ? d.parceiros : d.associados;
if(!fonte.length){ if(avisar) toast('O cadastro de '+d.cliente.nome+' não tem '+
(st.tipo==='PARCEIRO'?'parceiro externo':'participante do escritório')+' com percentual.'); return false; }
st.itens = fonte.map(x=>({id:x.id, nome:x.nome, pct:x.pct}));
finPartRender(campoId);
if(avisar) toast('Preenchido a partir do cadastro de '+d.cliente.nome+'.');
return true;
}
/* Ao trocar o cliente, preenche só o que ainda está vazio — quem já mexeu
à mão não perde o que escreveu. */
function finPartSincCliente(){
['t-fin-parc-box','t-fin-assoc-box'].forEach(id=>{
const st=FINP[id]; if(!st || !$(id)) return;
const vazio = !st.itens.some(x=>x.nome);
if(vazio) finPartPuxar(id,false); else finPartRender(id);
});
}
function valorParceiro(id){
const el=$(id); if(!el || !el.value) return null;
const op = el.options[el.selectedIndex];
return {id: op && op.dataset.fid ? +op.dataset.fid : null, nome: el.value};
}
function nomeParceiro(v){ return !v ? '' : (typeof v==='object' ? (v.nome||'') : String(v)); }
/* "MARIA (30%) · JOÃO (10%)" — usado no histórico e nos cartões */
function finPartResumo(lista){
return (lista||[]).filter(x=>x && x.nome)
.map(x=> x.nome + ((x.pct==null||x.pct==='') ? '' : ' ('+pctFmt(+x.pct)+')'))
.join(' · ');
}
function selMembro(id,val,vazio){
return '';
}
/* ================= seleção com busca por digitação ================= */
const MS = {};
function msHTML(id, ph, extra){
return '
'+
'
'+
''+
''+
'
'+
''+
(extra||'')+'
';
}
function msInit(id, itens, sel, opts){ MS[id]={itens:itens, sel:(sel||[]).slice(), unico:!!(opts&&opts.unico)}; msChips(id); }
/* devolve o texto do item escolhido em campos de seleção única */
function msValor(id){ const m=MS[id]; if(!m||!m.sel.length) return '';
const it=m.itens.find(x=>x.id===m.sel[0]); return it?it.nome:''; }
function msDefinir(id, nome){
const m=MS[id]; if(!m) return;
if(!nome){ m.sel=[]; msChips(id); return; }
let it=m.itens.find(x=>x.nome===nome);
if(!it){ it={id: (Math.max(0,...m.itens.map(x=>x.id))+1), nome:nome, sub:'fora da lista padrão'}; m.itens.push(it); }
m.sel=[it.id]; msChips(id);
}
function msSel(id){ return (MS[id]&&MS[id].sel)||[]; }
function msFoco(id){ const i=$(id+'-inp'); if(i && !i.disabled) i.focus(); }
function msFechar(id){ setTimeout(()=>{ const d=$(id+'-drop'); if(d) d.classList.remove('on'); }, 160); }
function msChips(id){
const m=MS[id]; if(!m) return;
const el=$(id+'-chips'); if(!el) return;
el.innerHTML = m.sel.map(v=>{
const it=m.itens.find(x=>x.id===v);
return ''+esc(it?it.nome:'—')+''+
'';
}).join('');
const inp=$(id+'-inp');
if(inp) inp.placeholder = m.sel.length ? 'Adicionar mais…' : inp.dataset.ph || inp.placeholder;
msEuAtualizar(id);
}
function msBuscar(id){
const m=MS[id]; if(!m) return;
const inp=$(id+'-inp'), d=$(id+'-drop');
const q=(inp.value||'').trim().toLowerCase();
/* em campo de escolha única, focar sem digitar já mostra a lista inteira */
if(!q && m.unico && !m.sel.length){
d.innerHTML = m.itens.slice(0,12).map((x,i)=>'').join('');
d.classList.add('on'); return;
}
if(!q){ d.classList.remove('on'); d.innerHTML=''; return; }
const achados = m.itens.filter(x=> m.sel.indexOf(x.id)<0 &&
((x.nome||'').toLowerCase().indexOf(q)>=0 || (x.busca||'').toLowerCase().indexOf(q)>=0)).slice(0,10);
d.innerHTML = achados.length
? achados.map((x,i)=>'').join('')
: '
Nada encontrado para “'+esc(inp.value)+'”.
';
d.classList.add('on');
/* garante que a lista caiba na área visível do formulário */
const cx = d.closest('.m-bd');
if(cx){ const rd=d.getBoundingClientRect(), rc=cx.getBoundingClientRect();
if(rd.bottom > rc.bottom - 6) cx.scrollTop += (rd.bottom - rc.bottom + 14); }
}
function msAdd(id,v){
const m=MS[id]; if(!m || m.sel.indexOf(v)>=0) return;
if(m.unico) m.sel=[v]; else m.sel.push(v);
$(id+'-inp').value=''; msChips(id);
if(m.unico){ const d=$(id+'-drop'); if(d){ d.classList.remove('on'); d.innerHTML=''; } } else msBuscar(id);
if(m.onChange) m.onChange();
const f=document.querySelector('[data-for="'+id+'"]'); if(f) f.classList.remove('err');
$(id+'-inp').focus();
}
function msRemover(id,v){
const m=MS[id]; if(!m) return;
m.sel = m.sel.filter(x=>x!==v); msChips(id);
if(m.onChange) m.onChange();
}
function msTecla(e,id){
const m=MS[id]; if(!m) return;
if(e.key==='Enter'){ e.preventDefault();
const b=$(id+'-drop').querySelector('button'); if(b) b.click(); return; }
if(e.key==='Escape'){ $(id+'-drop').classList.remove('on'); return; }
if(e.key==='Backspace' && !e.target.value && m.sel.length){ msRemover(id, m.sel[m.sel.length-1]); }
}
/* ---------- atalho "Eu mesmo(a)" ----------
Atribuir um registro a si mesma é o caso mais comum, e digitar o próprio
nome toda vez é atrito puro. O botão faz isso em um clique, e a própria
pessoa também aparece no topo da lista, rotulada. */
function pessoasCampo(lista){
const arr = (lista||atribuiveis()).map(m=>({id:m.id, nome:m.nome,
sub:(m.id===S.usuario ? 'Eu mesmo(a) · ' : '')+(m.funcao||'')+
(m.status==='pendente'?' · aguardando liberação':''),
busca:[(m.funcao||''), (m.email||''), (m.id===S.usuario?'eu mesmo eu mesma mim comigo':'')].join(' ')}));
arr.sort((a,b)=> (b.id===S.usuario?1:0)-(a.id===S.usuario?1:0));
return arr;
}
function msEuAtivar(id, rotulo){
const c=$(id); if(!c) return;
if(!$(id+'-euw')){
const w=document.createElement('div');
w.className='ms-eu'; w.id=id+'-euw';
w.innerHTML=''+
''+esc(rotulo||'Entra como '+((eu()&&eu().nome)||'você')+' sem precisar digitar o nome.')+'';
c.appendChild(w);
}
msEuAtualizar(id);
}
function msEuMesmo(id){
const m=MS[id]; if(!m) return;
if(!m.itens.some(x=>x.id===S.usuario)){ toast('Seu usuário não está disponível neste campo.'); return; }
if(m.sel.indexOf(S.usuario)>=0){ msRemover(id, S.usuario); return; }
msAdd(id, S.usuario);
}
function msEuAtualizar(id){
const b=$(id+'-eu'), m=MS[id]; if(!b||!m) return;
const tem = m.itens.some(x=>x.id===S.usuario);
const cx=$(id), travado = !!(cx && cx.classList.contains('trava'));
const w=$(id+'-euw'); if(w) w.style.display = (tem && !travado) ? '' : 'none';
const posto = m.sel.indexOf(S.usuario)>=0;
b.classList.toggle('on', posto);
b.innerHTML = posto ? '\u2713 Eu mesmo(a)' : '\uFF0B Eu mesmo(a)';
b.title = posto ? 'Clique para tirar seu nome deste campo.' : 'Coloca você neste campo em um clique.';
}
function msTravar(id,trava){
const c=$(id); if(!c) return;
c.classList.toggle('trava',!!trava);
const eb=$(id+'-euw'); if(eb) eb.style.display = trava ? 'none' : '';
const i=$(id+'-inp'); if(i){ i.disabled=!!trava; if(trava) i.value=''; }
const d=$(id+'-drop'); if(d) d.classList.remove('on');
}
/* ---- "não relacionado a cliente" ---- */
function toggleSemCliente(){
UI.semCliente = !UI.semCliente;
if(UI.semCliente){ MS['t-cli'].sel=[]; msChips('t-cli'); }
aplicarSemCliente(); syncAreas();
}
function aplicarSemCliente(){
const b=$('t-semcli'); if(b){ b.classList.toggle('on',!!UI.semCliente);
b.innerHTML = (UI.semCliente?'✓ ':'') + 'Não relacionado a cliente'; }
msTravar('t-cli', !!UI.semCliente);
const f=document.querySelector('[data-for="t-cli"]'); if(f && UI.semCliente) f.classList.remove('err');
}
/* O motivo só aparece quando a data de um registro já salvo muda. */
function dataMudou(){
if($('t-rec')) recMudou();
const w = document.querySelector('[data-for="t-data-motivo"]');
if(!w) return;
const orig = (UI.tarefaEdit && UI.tarefaEdit.dataOrig) || '';
const mudou = !!orig && $('t-data').value !== orig;
w.style.display = mudou ? '' : 'none';
if(!mudou && $('t-data-motivo')) $('t-data-motivo').value = '';
}
/* =========================================================
RECORRÊNCIA DAS TAREFAS
Um prazo que volta todo mês, uma conferência semanal, a
renovação anual de uma certidão. Ao concluir a ocorrência
atual, a plataforma já deixa a próxima agendada.
Não vale para o tipo Financeiro, que tem vida própria na
Gestão Financeira.
========================================================= */
/* Fins de semana e feriados nacionais, inclusive os móveis calculados a partir
da Páscoa. (O módulo financeiro tem a sua própria cópia, no documento dele.) */
function recPascoa(ano){
const a=ano%19, b=Math.floor(ano/100), c=ano%100;
const d=Math.floor(b/4), e=b%4, f=Math.floor((b+8)/25), g=Math.floor((b-f+1)/3);
const h=(19*a+b-d-g+15)%30, i=Math.floor(c/4), k=c%4;
const l=(32+2*e+2*i-h-k)%7, m=Math.floor((a+11*h+22*l)/451);
const mes=Math.floor((h+l-7*m+114)/31), dia=((h+l-7*m+114)%31)+1;
return new Date(ano,mes-1,dia);
}
const _recFeriados = {};
function recFeriadosDoAno(ano){
if(_recFeriados[ano]) return _recFeriados[ano];
const set = new Set();
const marca = d => set.add(d.getFullYear()+'-'+d.getMonth()+'-'+d.getDate());
[[0,1],[3,21],[4,1],[8,7],[9,12],[10,2],[10,15],[10,20],[11,25]].forEach(([m,d])=>marca(new Date(ano,m,d)));
const pa = recPascoa(ano), soma = n => { const x=new Date(pa); x.setDate(x.getDate()+n); return x; };
[-48,-47,-2,60].forEach(n=>marca(soma(n))); /* carnaval (seg e ter), sexta santa, corpus christi */
_recFeriados[ano] = set; return set;
}
function recEhDiaUtil(d){
const s = d.getDay(); if(s===0 || s===6) return false;
return !recFeriadosDoAno(d.getFullYear()).has(d.getFullYear()+'-'+d.getMonth()+'-'+d.getDate());
}
function recSomarDiasUteis(base, n){
const d = new Date(base.getTime()); let c = 0, guarda = 0;
while(c < n && guarda++ < 4000){ d.setDate(d.getDate()+1); if(recEhDiaUtil(d)) c++; }
return d;
}
const REC_FREQS = [
{k:'diaria', n:'Diária', u:'dias', hint:'todo dia'},
{k:'semanal', n:'Semanal', u:'semanas', hint:'a cada 7 dias'},
{k:'mensal', n:'Mensal', u:'meses', hint:'no mesmo dia do mês seguinte'},
/* "toda última segunda-feira do mês" não cabe em "a cada 30 dias": o dia do
mês muda a cada rodada. Esta frequência marca pela posição da semana. */
{k:'diasemana', n:'Dia da semana do mês', u:'meses', hint:'ex.: toda última segunda-feira'},
{k:'anual', n:'Anual', u:'anos', hint:'na mesma data do ano seguinte'},
{k:'personalizada', n:'Personalizada', u:'', hint:'você define o intervalo'}
];
const REC_ORDINAIS = [{k:'1',n:'primeira'},{k:'2',n:'segunda'},{k:'3',n:'terceira'},
{k:'4',n:'quarta'},{k:'ultima',n:'última'}];
const REC_SEMANA = [{k:0,n:'domingo'},{k:1,n:'segunda-feira'},{k:2,n:'terça-feira'},{k:3,n:'quarta-feira'},
{k:4,n:'quinta-feira'},{k:5,n:'sexta-feira'},{k:6,n:'sábado'}];
/* A data da N-ésima (ou da última) ocorrência de um dia da semana num mês. */
function recDataOrdinal(ano, mes, ord, dow){
const primeiro = new Date(ano, mes, 1);
ano = primeiro.getFullYear(); mes = primeiro.getMonth();
dow = ((+dow % 7) + 7) % 7;
if(String(ord)==='ultima'){
const ultimo = new Date(ano, mes+1, 0);
const recuo = (ultimo.getDay() - dow + 7) % 7;
return new Date(ano, mes, ultimo.getDate() - recuo);
}
const n = Math.max(1, Math.min(4, parseInt(ord,10) || 1));
const avanco = (dow - primeiro.getDay() + 7) % 7;
return new Date(ano, mes, 1 + avanco + (n-1)*7);
}
function recOrdinalNome(k){ const o=REC_ORDINAIS.find(x=>x.k===String(k)); return o?o.n:'primeira'; }
function recSemanaNome(k){ const d=REC_SEMANA.find(x=>x.k===(+k)); return d?d.n:'segunda-feira'; }
const REC_UNIDADES = [{k:'dias',n:'dia(s)'},{k:'semanas',n:'semana(s)'},{k:'meses',n:'mês(es)'},{k:'anos',n:'ano(s)'}];
/* Vale para todos os tipos, o Financeiro inclusive: uma cobrança mensal, a
conferência do caixa e a emissão de nota se repetem como qualquer prazo. */
function recPodeTer(tipo){ return !!tipo; }
function recPadrao(){ return {ativo:false, freq:'mensal', cada:1, unidade:'meses', base:'corridos',
ordinal:'1', diaSemana:1, ate:'', vezes:0, feitas:0}; }
function recDe(t){
const r = Object.assign(recPadrao(), (t && t.rec) || {});
r.cada = Math.max(1, parseInt(r.cada,10) || 1);
r.vezes = Math.max(0, parseInt(r.vezes,10) || 0);
return r;
}
/* Empurra para o próximo dia útil quando a contagem é em dias úteis. */
function recDiaUtil(d){
const x = new Date(d.getTime());
let guarda = 0;
while(!recEhDiaUtil(x) && guarda++ < 30) x.setDate(x.getDate()+1);
return x;
}
/* Soma meses preservando o fim do mês: 31/01 + 1 mês = 28/02. */
function recSomarMeses(d, n){
const dia = d.getDate();
const x = new Date(d.getFullYear(), d.getMonth()+n, 1);
const ultimo = new Date(x.getFullYear(), x.getMonth()+1, 0).getDate();
x.setDate(Math.min(dia, ultimo));
return x;
}
/* A próxima data de uma tarefa recorrente, a partir da data informada. */
function recProxima(dataISO, r){
const base = pd(dataISO); if(!base) return '';
const cada = Math.max(1, parseInt(r.cada,10)||1);
const uteis = r.base === 'uteis';
let d;
/* Pela posição da semana no mês: a primeira, segunda… ou a última
ocorrência daquele dia da semana, a cada N meses. */
if(r.freq==='diasemana'){
const dow = (r.diaSemana==null || r.diaSemana==='') ? base.getDay() : (+r.diaSemana || 0);
const ord = r.ordinal || '1';
let passo = cada, guarda = 0;
d = recDataOrdinal(base.getFullYear(), base.getMonth()+passo, ord, dow);
/* nunca antes da data de partida — com "última" e meses curtos, pode acontecer */
while(d && iso(d) <= dataISO && guarda++ < 24){
passo += cada;
d = recDataOrdinal(base.getFullYear(), base.getMonth()+passo, ord, dow);
}
if(!d) return '';
if(uteis) d = recDiaUtil(d);
return iso(d);
}
const unidade = r.freq==='personalizada' ? (r.unidade||'dias')
: r.freq==='diaria' ? 'dias' : r.freq==='semanal' ? 'semanas'
: r.freq==='mensal' ? 'meses' : 'anos';
if(unidade==='dias'){
if(uteis){ d = recSomarDiasUteis(base, cada); }
else { d = new Date(base.getTime()); d.setDate(d.getDate()+cada); }
} else if(unidade==='semanas'){
d = new Date(base.getTime()); d.setDate(d.getDate()+7*cada);
} else if(unidade==='meses'){
d = recSomarMeses(base, cada);
} else {
d = new Date(base.getFullYear()+cada, base.getMonth(), base.getDate());
}
if(uteis && unidade!=='dias') d = recDiaUtil(d);
return iso(d);
}
/* ---------- a série ----------
Cada ocorrência é um registro próprio, e todas carregam o mesmo `serie`.
Assim a conclusão de hoje fica guardada com data, comentários e histórico
próprios, e a próxima nasce limpa — a recorrência não some quando alguém
marca o dia como concluído. */
function recAtiva(t){ const r = t && t.rec; return !!(r && r.ativo); }
function recSerieId(t){ return t ? (t.serie || t.id) : null; }
function recSerie(t){
const sid = recSerieId(t); if(sid==null) return [];
return S.tarefas.filter(x=>recSerieId(x)===sid)
.sort((a,b)=>String(a.data||'').localeCompare(String(b.data||'')) || (a.id-b.id));
}
function recIndice(t){
const l = recSerie(t);
const i = l.findIndex(x=>x.id===t.id);
return {n: i>=0 ? i+1 : 1, total: l.length};
}
/* O total previsto da série, quando há limite de repetições. */
function recTotalPrevisto(t){
const r = recDe(t);
return r.vezes ? r.vezes + 1 : 0;
}
/* Ainda cabe uma próxima ocorrência? */
function recCabeMais(t){
const r = recDe(t);
if(!r.ativo) return false;
if(r.vezes && (r.feitas||0) + 1 > r.vezes) return false;
const prox = recProximaValida(t, r);
if(!prox) return false;
if(r.ate && prox > r.ate) return false;
return true;
}
/* A próxima data que faz sentido: nunca no passado.
Quem conclui com atraso uma tarefa diária não quer a próxima marcada para
anteontem — a série avança até alcançar hoje, contando as ocorrências
puladas no limite de repetições. */
function recProximaValida(t, r){
const base = t.data || iso(hoje0());
let prox = recProxima(base, r);
if(!prox) return '';
const hoje = iso(hoje0());
let pulos = 0;
while(prox < hoje && pulos < 400){
const seguinte = recProxima(prox, r);
if(!seguinte || seguinte === prox) break;
if(r.ate && seguinte > r.ate) break;
prox = seguinte; pulos++;
}
return prox;
}
function recResumo(r, dataISO){
if(!r || !r.ativo) return '';
const cada = Math.max(1, parseInt(r.cada,10)||1);
const f = REC_FREQS.find(x=>x.k===r.freq) || REC_FREQS[2];
let txt;
if(r.freq==='diasemana'){
txt = 'toda '+recOrdinalNome(r.ordinal)+' '+recSemanaNome(r.diaSemana)+
(cada>1 ? ' a cada '+cada+' meses' : ' do mês');
} else if(r.freq==='personalizada'){
const u = (REC_UNIDADES.find(x=>x.k===(r.unidade||'dias'))||REC_UNIDADES[0]).n;
txt = 'a cada '+cada+' '+u;
} else {
txt = cada>1 ? 'a cada '+cada+' '+f.u : f.n.toLowerCase();
}
txt += r.freq==='diasemana'
? (r.base==='uteis' ? ', empurrando para o próximo dia útil se cair em feriado' : '')
: (r.base==='uteis' ? ', contando só dias úteis' : ', em dias corridos');
const prox = dataISO ? recProxima(dataISO, r) : '';
if(prox) txt += ' — a próxima cairia em '+fmtD(prox);
if(r.ate) txt += '; até '+fmtD(r.ate);
if(r.vezes) txt += '; no máximo '+r.vezes+' repetição(ões)';
return txt+'.';
}
/* ---------- o bloco no formulário ----------
O mesmo bloco serve a dois formulários — o de registros e o de
compromissos da agenda —, que convivem na mesma página. Por isso todo id
leva um prefixo: 't' para os registros, 'cp' para os compromissos. */
function recHTML(t, tipo, P, dataId){
P = P || 't';
if(P==='t' && !recPodeTer(tipo)) return '';
const r = recDe(t);
const on = !!r.ativo;
const ch = "'"+P+"'";
const ehComp = P==='cp';
return '
'+
''+
'
'+
'
'+
'
'+
'
'+
'
'+
''+
''+
'
'+
'
'+
'
'+
''+
''+
'
'+
'
'+
'
'+
''+
'mês(es)'+
'
'+
'
'+
'
'+
'
'+
'
'+
'
'+
'
'+
''+
'
'+
'
';
}
function recCampos(P){
P = P || 't';
const q = suf => $(P+'-rec'+(suf||''));
if(!q()) return recPadrao();
const freq = q('-freq') ? q('-freq').value : 'mensal';
return {
ativo: q().checked,
freq: freq,
/* na frequência por dia da semana, o "a cada" é o campo em meses */
cada: freq==='diasemana'
? Math.max(1, parseInt((q('-meses')||{}).value,10) || 1)
: Math.max(1, parseInt((q('-cada')||{}).value,10) || 1),
unidade: q('-unidade') ? q('-unidade').value : 'dias',
ordinal: q('-ordinal') ? q('-ordinal').value : '1',
diaSemana: q('-dsem') ? (+q('-dsem').value || 0) : 1,
base: q('-base') ? q('-base').value : 'corridos',
ate: q('-ate') ? (q('-ate').value||'') : '',
vezes: Math.max(0, parseInt((q('-vezes')||{}).value,10) || 0),
feitas: (P==='t' ? (UI.recFeitas||0) : 0)
};
}
function recMudou(P){
P = P || 't';
const q = suf => $(P+'-rec'+(suf||''));
if(!q()) return;
const on = q().checked;
const box = q('-box'); if(box) box.classList.toggle('on', on);
const cp = q('-campos'); if(cp) cp.style.display = on ? '' : 'none';
const cw = q('-cada-w');
const freq = q('-freq') ? q('-freq').value : '';
/* "a cada N" só aparece na personalizada; nas demais o intervalo é o próprio nome */
if(cw) cw.style.display = (on && freq==='personalizada') ? '' : 'none';
const sw = q('-sem-w'), mw = q('-mes-w');
if(sw) sw.style.display = (on && freq==='diasemana') ? '' : 'none';
if(mw) mw.style.display = (on && freq==='diasemana') ? '' : 'none';
/* na marcação por dia da semana, quem manda é o dia — a contagem em dias
úteis vira apenas a regra do feriado */
const bw = q('-base');
if(bw){
const lab = bw.closest('.recf') && bw.closest('.recf').querySelector('label');
if(lab) lab.textContent = freq==='diasemana' ? 'Se cair em feriado' : 'Contagem';
const op = bw.options;
if(op.length>=2){
op[0].textContent = freq==='diasemana' ? 'Mantém a data' : 'Dias corridos';
op[1].textContent = freq==='diasemana' ? 'Empurra para o próximo dia útil' : 'Somente dias úteis';
}
}
const n = q('-nota');
if(n){
if(!on){ n.textContent=''; }
else {
const r = recCampos(P);
const campoData = $(P==='cp' ? 'cp-data' : 't-data');
const dt = campoData ? campoData.value : '';
n.innerHTML = dt ? esc(recResumo(r, dt))
: 'Informe a data acima para ver quando cairia a próxima ocorrência.';
}
}
}
/* ---------- ao concluir, a próxima nasce agendada ----------
A ocorrência concluída fica intacta no histórico, com a data em que foi
entregue. A próxima é um registro novo, com data nova e o mesmo conteúdo —
é assim que a conclusão de um dia convive com a recorrência. */
function recGerarProxima(t, motivo){
const r = recDe(t);
if(!r.ativo || !recPodeTer(t.tipo)) return null;
/* já existe a próxima desta ocorrência? só conta se ela ainda estiver na base:
se alguém a excluiu, a série tem o direito de se refazer */
if(t.recFilha && S.tarefas.some(x=>x.id===t.recFilha)) return null;
const prox = recProximaValida(t, r);
if(!prox) return null;
if(r.ate && prox > r.ate) return null;
if(r.vezes && (r.feitas||0) + 1 > r.vezes) return null;
const nova = JSON.parse(JSON.stringify(t));
nova.id = novoId('tarefa');
nova.data = prox;
nova.status = 'A fazer';
nova.statusAnterior = null;
nova.concluidoEm = null;
nova.comentarios = [];
nova.historico = [];
nova.arquivos = [];
nova.criadoEm = agoraISO();
nova.criadoPor = t.criadoPor || eu().nome;
nova.criadoPorId = t.criadoPorId || eu().id;
nova.alteradoEm = null; nova.alteradoPor = null;
nova.tratadoEm = null;
nova.recFilha = null;
nova.recMae = t.id;
nova.serie = recSerieId(t);
nova.rec = Object.assign({}, r, {feitas:(r.feitas||0)+1});
/* subtarefas voltam desmarcadas: é uma nova rodada do mesmo trabalho */
if(nova.checklist && Array.isArray(nova.checklist.itens))
nova.checklist.itens.forEach(i=>{ i.feito = false; i.ok = false; });
/* no tipo Financeiro, a nova ocorrência é um lançamento novo: ela precisa
voltar à fila da Gestão Financeira, e não herdar a baixa da anterior */
if(nova.fin && typeof nova.fin==='object'){
nova.fin = Object.assign({}, nova.fin, {lancado:false, lancadoPor:null, lancadoEm:null, recebivelId:null});
}
const idx = (nova.rec.feitas||0) + 1;
const tot = recTotalPrevisto(nova);
hist(nova, 'Ocorrência '+idx+(tot?' de '+tot:'')+' da série, criada automaticamente pela recorrência ('+
recResumo(nova.rec,'').replace(/\.$/,'')+').');
S.tarefas.push(nova);
t.recFilha = nova.id;
t.serie = recSerieId(t);
t.rec = Object.assign({}, r);
hist(t, (motivo||'Concluída')+'. Próxima ocorrência agendada para '+fmtD(prox)+'.');
return nova;
}
/* =========================================================
CONCLUIR E REABRIR — um caminho só
Antes, marcar concluído pelo cartão, pelo menu de status ou pelo aviso de
vencimento não passava pela recorrência, e a série morria ali. Agora todos
os caminhos entram por aqui.
========================================================= */
function concluirTarefa(t, comoFoi, motivo){
if(!t) return null;
const antes = t.status;
t.statusAnterior = antes;
t.status = CONCLUIDA;
t.concluidoEm = agoraISO();
const proxima = recGerarProxima(t, 'Concluída '+(comoFoi||''));
if(!proxima) hist(t, 'Concluída '+(comoFoi||'')+(motivo?'. '+motivo:'')+'.');
else if(motivo) hist(t, motivo);
t.alteradoEm = agoraISO(); t.alteradoPor = eu().nome;
return proxima;
}
function reabrirTarefa(t, comoFoi){
if(!t) return;
t.status = t.statusAnterior && t.statusAnterior!==CONCLUIDA ? t.statusAnterior : 'A fazer';
t.concluidoEm = null;
const filha = t.recFilha ? S.tarefas.find(x=>x.id===t.recFilha) : null;
hist(t, 'Reaberta '+(comoFoi||'')+' (status voltou para '+t.status+')'+
(filha ? '. A próxima ocorrência, de '+fmtD(filha.data)+', continua agendada.' : '.'));
t.alteradoEm = agoraISO(); t.alteradoPor = eu().nome;
return filha;
}
/* Encerrar a série sem apagar o que já foi feito. */
async function recEncerrarSerie(id){
const t = S.tarefas.find(x=>x.id===id); if(!t) return;
if(!podeEditar(t)){ toast('Você não pode alterar este registro.'); return; }
if(!recAtiva(t)){ toast('Este registro não tem recorrência ativa.'); return; }
if(!await confirmar('Encerrar a recorrência desta série?\n\n'+
'As ocorrências já criadas continuam como estão. A plataforma apenas deixa de gerar as próximas.')) return;
recSerie(t).forEach(x=>{
if(x.rec){ x.rec = Object.assign({}, x.rec, {ativo:false}); }
if(x.status!==CONCLUIDA) hist(x, 'Recorrência encerrada por '+eu().nome+'. Esta ocorrência segue em aberto.');
});
hist(t, 'Recorrência encerrada.');
salvar(); redesenhar();
if($('ov-tarefa').classList.contains('on')) abrirTarefa(t.id);
toast('Recorrência encerrada. As ocorrências já criadas continuam.');
}
/* =========================================================
REPARO DA SÉRIE
Rede de segurança: se por qualquer motivo uma ocorrência recorrente foi
concluída sem gerar a seguinte — versão antiga da plataforma, exclusão da
próxima, sincronização entre aparelhos —, a série se refaz sozinha na
próxima abertura. A recorrência não depende de ninguém lembrar.
========================================================= */
function recReparar(silencioso){
if(!Array.isArray(S.tarefas)) return 0;
let n = 0;
/* trabalha sobre uma cópia: recGerarProxima insere na lista original */
S.tarefas.slice().forEach(t=>{
if(t.status !== CONCLUIDA) return;
if(!recAtiva(t) || !recPodeTer(t.tipo)) return;
if(t.recFilha && S.tarefas.some(x=>x.id===t.recFilha)) return;
/* a série já continua por outra ocorrência aberta? então não há buraco */
const sid = recSerieId(t);
if(S.tarefas.some(x=>x.id!==t.id && recSerieId(x)===sid && x.status!==CONCLUIDA)) return;
/* e não existe ocorrência posterior nenhuma nesta série? */
if(S.tarefas.some(x=>x.id!==t.id && recSerieId(x)===sid && String(x.data||'') > String(t.data||''))) return;
const nova = recGerarProxima(t, 'Recorrência retomada');
if(nova){ hist(nova, 'Criada pelo reparo automático da série: a ocorrência anterior tinha sido concluída sem gerar a seguinte.'); n++; }
});
if(n){
salvar();
if(!silencioso) setTimeout(()=>toast(n+' ocorrência(ões) recriada(s): a recorrência tinha se perdido e foi retomada.'), 1200);
}
return n;
}
/* Um resumo curto para o cartão e para o cabeçalho do registro. */
function recSelo(t){
if(!t) return '';
if(!recAtiva(t) && !t.serie) return '';
const idx = recIndice(t);
const r = recDe(t);
const tot = recTotalPrevisto(t);
const f = REC_FREQS.find(x=>x.k===r.freq);
const nome = r.freq==='personalizada'
? 'a cada '+Math.max(1,parseInt(r.cada,10)||1)+' '+((REC_UNIDADES.find(x=>x.k===(r.unidade||'dias'))||REC_UNIDADES[0]).n)
: (f?f.n.toLowerCase():'recorrente');
const pos = (r.feitas||0) + 1;
return {rot:'🔁 '+pos+(tot?'/'+tot:'ª'), titulo:'Ocorrência '+pos+(tot?' de '+tot:'')+' da série — '+nome+
(recAtiva(t)?'':' (recorrência encerrada)'), ativa:recAtiva(t), pos:pos, total:tot, nome:nome, idx:idx};
}
function campo(cls,label,req,inner,id,hint){
return '
'+
inner + (hint?'
'+hint+'
':'') + '
Campo obrigatório.
';
}
/* Alguns status pedem justificativa por escrito. */
function statusMudou(){
const st = $('t-status') ? $('t-status').value : '';
/* o campo assume a cor do status escolhido */
const sel=$('t-status'), bol=$('t-stbolha');
if(sel){ sel.className = 'stsel ' + (STATUS_CLS[st]||'st-fazer'); }
if(bol){ bol.style.background = STATUS_DOT[st]||'#6b7280'; }
const exige = STATUS_EXIGE_NOTA.indexOf(st)>=0;
const req=$('t-cmt-req'), nota=$('t-cmt-nota');
if(req) req.style.display = exige?'':'none';
if(nota) nota.style.display = exige?'':'none';
if(!exige){ const f=document.querySelector('[data-for="t-cmt-novo"]'); if(f) f.classList.remove('err'); }
}
function syncAreas(){
const ids=msSel('t-cli');
const a=areasDosClientes(ids);
const el=$('t-area'); if(el) el.value = a.length? a.join(' · ') : '';
const pe=$('t-proc'); if(!pe) return;
const auto = ids.map(id=>{const c=cliente(id); return c&&c.processo?c.processo:'';}).filter(Boolean).join(' · ');
/* só sobrescreve enquanto a pessoa não digitou um número próprio */
if(!UI.procManual || !pe.value.trim() || pe.value===UI.procAuto) pe.value = auto;
UI.procAuto = auto;
pe.placeholder = auto? '' : 'Nenhum processo no cadastro do cliente';
if($('t-fin-parc-box')) finPartSincCliente();
}
function coletar(){
const tipo = UI.tarefaEdit.tipo;
const o = {
tipo:tipo,
descricao: ($('t-desc').value||'').trim(),
clientes: msSel('t-cli'),
semCliente: !!UI.semCliente,
processo: ($('t-proc').value||'').trim(),
data: $('t-data').value||'',
hora: $('t-hora')?($('t-hora').value||''):'',
horaFim: $('t-hora-fim')?($('t-hora-fim').value||''):'',
status: $('t-status').value,
acao: msValor('t-acao'),
responsaveis: msSel('t-resp'),
link: ($('t-link').value||'').trim(),
arquivos: UI.arquivosTmp.slice(),
prioridade: UI.prioTmp||'Média',
checklist: JSON.parse(JSON.stringify(UI.checkTmp||{ativo:false,itens:[]}))
};
if(recPodeTer(tipo)) o.rec = recCampos();
o.areas = areasDosClientes(o.clientes);
if(tipo==='financeiro'){
o.fin = {tipo:$('t-fin-tipo').value, nf:$('t-fin-nf').value,
parceiros:finPartValor('t-fin-parc-box'), associados:finPartValor('t-fin-assoc-box')};
/* espelho de um nome só, para o que ainda lê o formato antigo */
o.fin.parceiro = o.fin.parceiros[0] || null;
o.fin.associado = o.fin.associados[0] || null;
}
return o;
}
/* Acha o registro em edição. Se a mescla o renumerou no meio do caminho, o
número muda mas a assinatura de criação não — é por ela que se reencontra. */
function acharRegistroEditado(){
const e = UI.tarefaEdit; if(!e) return null;
let t = S.tarefas.find(x=>x.id===e.id);
if(t) return t;
if(e.criadoEm){
t = S.tarefas.find(x=>x.criadoEm===e.criadoEm &&
(e.criadoPorId==null || String(x.criadoPorId)===String(e.criadoPorId)));
if(t) return t;
}
return null;
}
function marcarErro(id,ok){ const f=document.querySelector('[data-for="'+id+'"]'); if(f) f.classList.toggle('err',!ok); }
function validar(o){
const e=[];
marcarErro('t-desc', !!o.descricao); if(!o.descricao) e.push('Descrição');
const cliOk = o.clientes.length>0 || o.semCliente;
marcarErro('t-cli', cliOk); if(!cliOk) e.push('Cliente (ou marque "Não relacionado a cliente")');
marcarErro('t-data', !!o.data); if(!o.data) e.push(TIPOS[o.tipo].dataLabel);
marcarErro('t-status', !!o.status); if(!o.status) e.push('Status');
marcarErro('t-acao', !!o.acao); if(!o.acao) e.push('Ação a executar');
marcarErro('t-resp', o.responsaveis.length>0); if(!o.responsaveis.length) e.push('Responsável');
if(o.tipo==='financeiro'){
marcarErro('t-fin-tipo', !!o.fin.tipo); if(!o.fin.tipo) e.push('Tipo');
marcarErro('t-fin-nf', !!o.fin.nf); if(!o.fin.nf) e.push('Emitir NF');
}
if(o.link && !/^https?:\/\//i.test(o.link)) e.push('Link (precisa começar com http:// ou https://)');
if(o.horaFim && !o.hora) e.push('Horário de início (necessário para registrar o término)');
if(o.hora && o.horaFim && minutos(o.horaFim) <= minutos(o.hora)) e.push('Horário de término (precisa ser depois do início)');
return e;
}
async function salvarTarefa(){
const o = coletar();
const err = validar(o);
const anterior = UI.tarefaEdit.novo ? null : (S.tarefas.find(x=>x.id===UI.tarefaEdit.id)||{}).status;
const nota = cmtTexto() ? cmtValor() : '';
const exigeNota = STATUS_EXIGE_NOTA.indexOf(o.status)>=0 && o.status!==anterior;
const fNota = document.querySelector('[data-for="t-cmt-novo"]');
if(fNota) fNota.classList.toggle('err', exigeNota && !nota);
if(exigeNota && !nota) err.push('Comentário (obrigatório em "'+o.status+'")');
if(err.length){ const b=$('t-err'); b.innerHTML='Revise: '+err.map(esc).join(', ')+'.';
b.classList.remove('aviso'); b.classList.add('on');
$('ov-tarefa').scrollTop=0; return; }
$('t-err').classList.remove('on','aviso');
/* Só depois de o formulário passar: os anexos vão para o servidor e, na
base, fica o número de cada arquivo — não o arquivo. É isso que impede a
base de estourar o limite do navegador. Subir antes da validação deixaria
arquivo órfão lá toda vez que o formulário fosse recusado. */
if((UI.arquivosTmp||[]).some(a=>a && a.dataUrl && !a.ref)){
const bt = $('t-salvar');
const rot = bt ? bt.textContent : '';
if(bt){ bt.disabled = true; bt.textContent = 'Enviando anexos…'; }
try{ await anexosSubirLista(UI.arquivosTmp); }catch(e){}
if(bt){ bt.disabled = false; bt.textContent = rot; }
renderArquivos();
o.arquivos = UI.arquivosTmp.slice(); /* com as referências já trocadas */
}
if(UI.tarefaEdit.novo){
const t = Object.assign({id:novoId('tarefa')}, o, {
criadoPor: eu().nome, criadoPorId: eu().id, criadoEm: agoraISO(),
alteradoEm:null, alteradoPor:null, comentarios:[], historico:[],
concluidoEm: o.status===CONCLUIDA?agoraISO():null});
hist(t, 'Registro criado como '+TIPOS[t.tipo].kind.toLowerCase()+', prioridade '+t.prioridade.toLowerCase()+'.');
if(nota) t.comentarios.push({autor:eu().nome, em:agoraISO(), texto:nota});
if(t.rec && t.rec.ativo) t.serie = t.id;
S.tarefas.push(t);
cliAndamentoEm(t.clientes, t, cliAndamentoTexto(t, 'criado',
nota ? 'Com comentário de '+eu().nome : ''));
if(t.status===CONCLUIDA) concluirTarefa(t, 'no cadastro');
S.rascunhos = (S.rascunhos||[]).filter(r=>!(r.tipo===t.tipo && r.autorId===eu().id));
salvar(); renderRascunhos();
const painel = TIPOS[t.tipo].painel;
UI.tarefaEdit = null;
fechar('ov-tarefa');
const naAgenda = UI.painel==='agenda';
if(naAgenda){ if(t.data) agEstado().ref = t.data; }
else {
const alvo = PAINEIS.find(x=>x.k===painel);
/* registro financeiro não arrasta ninguém para a Gestão Financeira:
quem lançou continua onde estava, e o alerta espera lá. */
if(painel!==UI.painel && painel!=='financeiro' && (!alvo.perm || pode(alvo.perm))) UI.painel=painel;
if(PAINEIS_KANBAN[painel]){
const pp = panEstado(painel);
/* O painel volta sempre para "todas": o filtro por tipo é escolha
de quem está olhando, não consequência de ter cadastrado algo. */
if(PAINEIS_KANBAN[painel].todos) pp.tipo = 'todos';
pp.vista = t.status===CONCLUIDA?'concluido':'aberto';
const sv=$(painel+'-seg-vista');
if(sv) [...sv.children].forEach((b,i)=>b.classList.toggle('on',(i===0)===(pp.vista==='aberto')));
}
}
redesenhar();
if(t.tipo==='financeiro' && t.fin && FIN_LANCAVEIS.indexOf(t.fin.tipo)>=0){
hist(t,'Alerta de lançamento criado na Gestão Financeira ('+t.fin.tipo+').');
salvar(); redesenhar();
toast('Alerta criado na Gestão Financeira: '+t.fin.tipo+' aguardando lançamento.');
} else toast(TIPOS[t.tipo].kind+' criado em '+PAINEIS.find(x=>x.k===painel).nome+
(naAgenda?' e lançado na agenda de '+fmtD(t.data)+'.':'.'));
} else {
const t = acharRegistroEditado();
if(!t){
/* A base foi trocada por uma sincronização enquanto o formulário estava
aberto e este registro não existe mais. Antes isso estourava e a tela
fechava sem salvar — o registro "sumia". Agora avisa e mantém tudo o
que foi digitado, para não se perder nada. */
const b=$('t-err');
b.innerHTML='Este registro não está mais na base — pode ter sido excluído ou renumerado por outra pessoa '+
'enquanto você o editava. Nada do que você escreveu foi perdido: copie o que precisar antes de fechar, '+
'ou use Cancelar e abra o registro de novo.';
b.classList.remove('aviso'); b.classList.add('on');
$('ov-tarefa').scrollTop=0; return;
}
UI.tarefaEdit.id = t.id; /* segue o registro se ele foi renumerado */
const antes = t.status;
const cliAntes = (t.clientes||[]).slice();
const hAntes = (t.historico||[]).length;
/* salvar com mudança de status, de data, de responsável ou com comentário
conta como encaminhamento do que vence hoje */
const encaminhou = antes!==o.status || String(t.data||'')!==String(o.data||'') || !!nota ||
(t.responsaveis||[]).join(',')!==(o.responsaveis||[]).join(',');
diffHistorico(t,o);
Object.assign(t,o);
if(encaminhou) marcarTratado(t);
let proxima = null;
if(antes!==CONCLUIDA && o.status===CONCLUIDA){ proxima = concluirTarefa(t, 'no cartão'); }
else if(antes===CONCLUIDA && o.status!==CONCLUIDA){ reabrirTarefa(t, 'no cartão'); t.status = o.status; }
if(o.status!==CONCLUIDA) t.concluidoEm=null;
if(nota){ t.comentarios=t.comentarios||[]; t.comentarios.push({autor:eu().nome, em:agoraISO(), texto:nota}); }
t.alteradoEm=agoraISO(); t.alteradoPor=eu().nome;
/* espelha o andamento no cartão de cada cliente vinculado */
(function(){
const mudou = (t.historico||[]).slice(hAntes).map(h=>h.txt).join(' · ');
const depois = (t.clientes||[]).slice();
const saiu = cliAntes.filter(id=>depois.indexOf(id)<0);
const entrou = depois.filter(id=>cliAntes.indexOf(id)<0);
const segue = depois.filter(id=>cliAntes.indexOf(id)>=0);
if(saiu.length) cliAndamentoEm(saiu, t, cliAndamentoTexto(t, 'desvinculado deste cliente', ''));
if(entrou.length) cliAndamentoEm(entrou, t, cliAndamentoTexto(t, 'vinculado a este cliente', mudou));
if(segue.length && (mudou || nota))
cliAndamentoEm(segue, t, cliAndamentoTexto(t, 'atualizado',
[mudou, nota?'Com comentário de '+eu().nome:''].filter(Boolean).join(' · ')));
})();
salvar(); fechar('ov-tarefa');
if(UI.painel==='agenda' && t.data && t.status!==CONCLUIDA) agEstado().ref = t.data;
redesenhar();
toast(proxima
? 'Concluído. Próxima ocorrência já agendada para '+fmtD(proxima.data)+'.'
: (t.status===CONCLUIDA ? 'Registro concluído e retirado da agenda.' : 'Registro atualizado.'));
}
}
/* "09:00", "09:00–10:30" ou vazio: usado nos cartões, na agenda e nas listas. */
function faixaHora(t){
const h1 = (t && t.hora) ? t.hora : '';
const h2 = (t && t.horaFim) ? t.horaFim : '';
if(!h1) return h2 ? 'até '+h2 : '';
return h2 ? h1+'–'+h2 : h1;
}
function nomeLista(ids,fn){ return (ids||[]).map(fn).filter(Boolean).join(', ') || '—'; }
function diffHistorico(t,o){
const reg=[];
const cmp=(rot,a,b)=>{ if(String(a==null?'':a)!==String(b==null?'':b))
reg.push(rot+': "'+(a||'—')+'" → "'+(b||'—')+'"'); };
cmp('Descrição', t.descricao, o.descricao);
cmp('Status', t.status, o.status);
cmp('Prioridade', t.prioridade||'Média', o.prioridade);
cmp('Ação a executar', t.acao, o.acao);
const mudouData = String(t.data||'')!==String(o.data||'');
cmp(TIPOS[o.tipo].dataLabel, t.data?fmtD(t.data):'', o.data?fmtD(o.data):'');
if(mudouData){
const mot = $('t-data-motivo') ? ($('t-data-motivo').value||'').trim() : '';
reg.push('Motivo da nova data: '+(mot||'não informado'));
}
cmp('Horário de início', t.hora, o.hora);
cmp('Horário de término', t.horaFim, o.horaFim);
cmp('Recorrência', recResumo(recDe(t),'') || 'não', recResumo(o.rec||{},'') || 'não');
cmp('Processo', t.processo, o.processo);
cmp('Link', t.link, o.link);
cmp('Clientes', nomeLista(t.clientes,id=>{const c=cliente(id);return c&&c.nome;}),
nomeLista(o.clientes,id=>{const c=cliente(id);return c&&c.nome;}));
const antesR = nomeLista(t.responsaveis,id=>{const m=membro(id);return m&&m.nome;});
const depoisR = nomeLista(o.responsaveis,id=>{const m=membro(id);return m&&m.nome;});
cmp('Responsáveis', antesR, depoisR);
if(String(antesR||'')!==String(depoisR||'')){
const mt = $('t-transf-motivo') ? ($('t-transf-motivo').value||'').trim() : '';
reg.push('Motivo da troca de responsável: '+(mt||'não informado'));
}
const na=(t.arquivos||[]).length, nb=(o.arquivos||[]).length;
if(na!==nb) reg.push('Anexos: '+na+' → '+nb);
if(o.fin){ const f=t.fin||{};
cmp('Tipo financeiro', f.tipo, o.fin.tipo);
cmp('Emitir NF', f.nf, o.fin.nf);
cmp('Advogado parceiro', finPartResumo(finListaDe(f,'parceiros','parceiro')),
finPartResumo(o.fin.parceiros));
cmp('Associado envolvido', finPartResumo(finListaDe(f,'associados','associado')),
finPartResumo(o.fin.associados));
}
if(reg.length) hist(t, reg.join(' · '));
}
/* ================= rascunhos de registro novo ================= */
function capturarRascunho(){
if(!UI.tarefaEdit || !UI.tarefaEdit.novo) return;
if(!$('t-desc')) return;
let o; try{ o = coletar(); }catch(e){ return; }
const temConteudo = o.descricao || o.clientes.length || o.semCliente || o.data || o.acao || o.responsaveis.length ||
o.link || (o.arquivos||[]).length || (o.checklist&&o.checklist.itens.length);
if(!temConteudo) return;
S.rascunhos = (S.rascunhos||[]).filter(r=>r.tipo!==o.tipo);
S.rascunhos.push({tipo:o.tipo, dados:o, em:agoraISO(), autorId:eu().id, autor:eu().nome});
salvar(); renderRascunhos();
toast('Rascunho guardado. Você pode recuperá-lo no canto da tela.');
}
function meusRascunhos(){ return (S.rascunhos||[]).filter(r=>r.autorId===eu().id); }
/* O rascunho hoje guarda tarefas e também o cadastro de cliente. */
function rascunhoRotulo(tipo){
if(tipo==='cliente') return 'Cadastro de cliente';
return TIPOS[tipo] ? TIPOS[tipo].kind : 'Registro';
}
function rascunhoResumo(r){
const d = r.dados||{};
if(r.tipo==='cliente') return d.nome || d.cpfCnpj || '(sem nome)';
return d.descricao || '(sem descrição)';
}
function renderRascunhos(){
const rs = meusRascunhos().filter(r=>r.tipo==='cliente' ? pode('gerir_clientes') : !!TIPOS[r.tipo]);
$('rasc').innerHTML = rs.map(r=>
'
Rascunho não salvo · '+esc(rascunhoRotulo(r.tipo))+'
'+
'
'+esc(rascunhoResumo(r))+'
'+
'
Guardado em '+fmtDH(r.em)+'
'+
'
'+
'
').join('');
}
async function descartarRascunho(tipo,avisar){
if(avisar && !await confirmar('Descartar este rascunho? Não há como recuperar depois.')) return;
S.rascunhos = (S.rascunhos||[]).filter(r=>!(r.tipo===tipo && r.autorId===eu().id));
salvar(); renderRascunhos();
if(avisar) toast('Rascunho descartado.');
}
function recuperarRascunho(tipo){
const r = (S.rascunhos||[]).find(x=>x.tipo===tipo && x.autorId===eu().id);
if(!r) return;
if(tipo==='cliente'){ editarCliente(null, r.dados); return; }
novo(tipo, r.dados);
}
function preencherForm(d){
if(!d) return;
$('t-desc').value = d.descricao||'';
if(MS['t-cli']){ MS['t-cli'].sel=(d.clientes||[]).slice(); msChips('t-cli'); }
if(MS['t-resp']){ MS['t-resp'].sel=(d.responsaveis||[]).slice(); msChips('t-resp'); }
UI.semCliente = !!d.semCliente; aplicarSemCliente();
if($('t-data')){ $('t-data').value = d.data||''; dataMudou(); }
if($('t-hora')) $('t-hora').value = d.hora||'';
if($('t-hora-fim')) $('t-hora-fim').value = d.horaFim||'';
if($('t-status')) $('t-status').value = d.status||'A fazer';
msDefinir('t-acao', d.acao||'');
if($('t-proc')) { $('t-proc').value = d.processo||''; UI.procManual = !!d.processo; }
$('t-link').value = d.link||''; checaLinkTarefa();
UI.arquivosTmp = (d.arquivos||[]).slice(); renderArquivos();
UI.prioTmp = d.prioridade||'Média'; renderPrio();
UI.checkTmp = d.checklist ? JSON.parse(JSON.stringify(d.checklist)) : {ativo:false,itens:[]}; renderChecklist();
if(d.fin){ if($('t-fin-tipo')) $('t-fin-tipo').value=d.fin.tipo||'';
if($('t-fin-nf')) $('t-fin-nf').value=d.fin.nf||'';
if($('t-fin-parc-box')){ finPartInit('t-fin-parc-box','PARCEIRO', finListaDe(d.fin,'parceiros','parceiro'));
finPartRender('t-fin-parc-box'); }
if($('t-fin-assoc-box')){ finPartInit('t-fin-assoc-box','ASSOCIADO', finListaDe(d.fin,'associados','associado'));
finPartRender('t-fin-assoc-box'); } }
syncAreas();
}
async function cancelarTarefa(){
if(UI.tarefaEdit && UI.tarefaEdit.novo){
let o=null; try{ o=coletar(); }catch(e){}
const tem = o && (o.descricao||o.clientes.length||o.data||o.acao||o.responsaveis.length);
if(tem && !await confirmar('Descartar o que você preencheu? Se preferir guardar, feche a janela clicando fora dela — aí fica um rascunho.')) return false;
}
UI.tarefaEdit=null; fechar('ov-tarefa');
return true;
}
/* atalho do formulário vazio: só navega se o descarte for confirmado */
async function irCadastrarCliente(){
if(await cancelarTarefa()===false) return;
irPara('clientes'); setTimeout(()=>editarCliente(null),150);
}
async function excluirTarefa(){
const id=UI.tarefaEdit.id; if(!id) return;
const t=S.tarefas.find(x=>x.id===id); if(!t) return;
if(!podeExcluir(t)){
toast(t.status===CONCLUIDA
? 'Registros concluídos ficam no histórico e só a Administração pode excluí-los.'
: 'Com o acesso "Meu acesso" você só pode excluir registros criados por você.');
return; }
if(!await confirmar('Excluir este registro definitivamente?')) return;
S.tarefas = S.tarefas.filter(t=>t.id!==id);
salvar(); fechar('ov-tarefa'); redesenhar(); toast('Registro excluído.');
}
function travarForm(ro){
document.querySelectorAll('#ov-tarefa .m-bd input,#ov-tarefa .m-bd select,#ov-tarefa .m-bd textarea')
.forEach(el=>{ if(el.id==='t-cmt-novo') return;
if(el.id==='t-criado'||el.id==='t-alt'||el.id==='t-area') return;
el.disabled = ro; });
const cx = $('t-cmt-novo');
if(cx) cx.setAttribute('contenteditable', 'true'); /* comentar é permitido mesmo em leitura */
$('t-drop').style.display = ro?'none':'';
document.querySelectorAll('#t-cli .cx,#t-resp .cx,#t-acao .cx,#t-semcli').forEach(el=>{ el.disabled=ro; el.style.display=ro?'none':''; });
if(ro){ msTravar('t-cli',true); msTravar('t-resp',true); msTravar('t-acao',true); }
else { msTravar('t-cli', !!UI.semCliente); msTravar('t-resp',false); msTravar('t-acao',false); }
document.querySelectorAll('#t-prio .pb,#t-check button,#t-check input').forEach(el=>{ el.disabled=ro;
if(ro) el.style.pointerEvents='none'; else el.style.pointerEvents=''; });
const _t = UI.tarefaEdit.novo?null:S.tarefas.find(x=>x.id===UI.tarefaEdit.id);
$('t-del').style.display = (!ro && _t && podeExcluir(_t))?'':'none';
document.querySelectorAll('#t-files .del').forEach(b=>b.style.display = ro?'none':'');
}
/* ---- anexos ---- */
function renderArquivos(){
$('t-files').innerHTML = UI.arquivosTmp.map((a,i)=>
'
'+esc(
modo==='enviada' ? 'Enviado por '+(x.feitoPor||'—')+' em '+fmtDH(x.feitoEm)
: modo==='naoenviar' ? 'Marcada como "não enviar" por '+(x.naoEnviarPor||'—')+' em '+fmtDH(x.naoEnviar)
: modo==='aguardando' ? 'Contrato quitado, mas ainda há nota deste contrato sem marcação de emitida.'
: nfDetalhe(x))+'
A plataforma acompanha, na Gestão Financeira, os contratos de clientes marcados com '+
'nota fiscal SIM. O aviso de envio entra aqui quando o contrato está quitado e todas as notas dele '+
'já foram emitidas — a marcação feita em Gestão Financeira › Notas a emitir. Quitar o contrato, '+
'sozinho, não basta: não há o que mandar ao cliente antes de a nota existir. '+
'Em janeiro, entra também um aviso por cliente que teve pagamento no ano que fechou — muita gente precisa '+
'das notas para a declaração do Imposto de Renda. '+
'O aviso reaparece todo dia até ser marcado como enviado, ou até você dizer que aquele cliente '+
'não vai receber a nota.
'+
(pend.length
? '
'+
'Use na primeira vez, para limpar de uma vez os contratos que você já resolveu antes da plataforma.
'+
pend.map(x=>linha(x,'pendente')).join('')
: '
Nenhuma nota fiscal pendente de envio.
');
const espera = nfAguardando();
if(espera.length){
h += '
Aguardando a emissão das notas '+espera.length+' contrato(s)
'+
'
Contratos quitados cujas notas ainda não foram todas marcadas como emitidas em '+
'Gestão Financeira › Notas a emitir. Não há o que enviar ao cliente antes disso — '+
'eles voltam para a fila de envio sozinhos assim que a emissão for concluída.
Somente a Administração edita as listas do sistema.
';
const st = lsEstado();
const q = (st.q||'').toLowerCase().trim();
let h = '
Listas do sistema
'+
'
Aqui ficam os campos que oferecem opções prontas — a área do Direito, o tipo de ação, '+
'a origem do cliente, as ações de cada tipo de registro, as etiquetas. O que você alterar passa a valer '+
'para toda a equipe no menu correspondente. Cadastros antigos não mudam sozinhos: se uma opção sair '+
'da lista, quem já a usava continua com ela até ser editado.
'+
'
Ficam de fora, de propósito, os campos que buscam '+
'gente já cadastrada (clientes, sócios, associados, parceiros) e os que carregam regra própria — o '+
'Status e a Prioridade das tarefas, que definem cores, alertas e a coluna do quadro.
Integrantes do escritório '+lista.length+' cadastrado(s)
'+
(gerir? '
Ao cadastrar OAB e UF, a plataforma passa a poder buscar processos vinculados àquele registro quando a integração com os tribunais estiver ligada.
':
'
Seu perfil enxerga apenas o próprio cadastro. Para ver ou alterar a equipe, fale com a Administração.
')+
(linhas||'
Nenhum integrante cadastrado.
')+
(gerir?'
'+
'
'+
'
Quem tem a função '+esc(FUNCAO_ASSOCIADO)+' aparece automaticamente no cadastro '+
'Advogados parceiros e associados da Gestão Financeira — e o contrário também vale.
':'')+
(gerir? '
Quem entra na plataforma
'+
'
Quem abre a plataforma é definido pelo login: cada integrante cadastrado aqui cria a própria senha no primeiro acesso '+
'e enxerga o que as atribuições deste cadastro permitem. As senhas ficam em Configuração › Acesso e senha.
' : '');
}
/* Identidade do escritório — só a Administração edita, e o que se define aqui
aparece no cabeçalho, na tela de login e nos documentos gerados. */
function campoEscr(k, rot, dica, ph){
const e=escritorio();
return '
'+
''+
(dica?'
'+dica+'
':'')+'
';
}
function cfgEscritorio(){
if(!ehAdmin()) return '
Somente a Administração altera os dados do escritório.
';
const e=escritorio();
return '
Identificação
'+
'
É este nome que aparece no alto de todas as telas, na tela de login e na Gestão Financeira.
'+
'
'+
campoEscr('nome','Nome do escritório','','Nome como consta no contrato social')+
campoEscr('sigla','Sigla (símbolo do login)','Duas ou três letras, usadas quando não há logo carregada.','SB')+
campoEscr('cnpj','CNPJ','','00.000.000/0001-00')+
campoEscr('oab','Inscrição da sociedade na OAB','','OAB/SC 0.000')+
campoEscr('endereco','Endereço','','Rua, número, bairro, cidade/UF')+
campoEscr('telefone','Telefone','','(00) 00000-0000')+
campoEscr('email','E-mail','','contato@escritorio.com.br')+
campoEscr('site','Site','','www.escritorio.com.br')+
'
'+
'
Logo
'+
'
O símbolo aparece com o menu recolhido e a logo completa com o menu expandido. '+
'O que vai na tela de login você escolhe logo abaixo. PNG com fundo transparente fica melhor sobre o azul.
'+
'
'+
'
'+
'
'+(S.cfg.logoIcone?'':LOGO_ICONE)+'
'+
'
'+
(S.cfg.logoIcone?'':'')+'
'+
'
'+
'
'+
'
'+(S.cfg.logoFull?'':LOGO_FULL)+'
'+
'
'+
(S.cfg.logoFull?'':'')+'
'+
'
'+
'
'+
'
Tela de login
'+
'
Escolha o que aparece no alto do cartão de acesso — é a primeira coisa que a equipe vê ao abrir a plataforma.
Formato em faixa (horizontal) rende melhor aqui. PNG com fundo transparente fica bem sobre o cartão branco.
'+
'
'
: '')+
'
'+
'
'+marcaLogin().html+'
'+
(marcaLogin().larga?'':'
'+esc(nomeEscritorio())+'
')+
'
Acessar a plataforma
'+
'
Entrar
'+
'
'+
cfgDrive()+
(SRV.ativo ? '
Consulta processual base nacional do CNJ
'+
'
O botão Consultar processo, no cadastro do cliente, busca as movimentações na '+
'API pública do DataJud, do Conselho Nacional de Justiça. Ela já vem configurada com a chave pública '+
'divulgada pelo próprio CNJ — este campo só é necessário se um dia o CNJ trocar essa chave.
'+
'
'+
''+
'
A chave atual fica publicada em datajud-wiki.cnj.jus.br › API Pública › Acesso.
'+
''+
'
' : '');
}
/* ---------- Configuração › Escritório › Busca no Google Drive ----------
Os dois códigos abaixo não são senhas: o Google os expõe no próprio
código da página, por desenho. O que protege é a restrição por site
feita no Google Cloud. Por isso ficam aqui, e não no servidor. */
function cfgDrive(){
const d = driveCfg(), ligado = driveAtivo();
return '
Busca da pasta no Google Drive '+
(ligado?'ativa':'não configurada')+'
'+
'
Com estes dois códigos preenchidos, o cadastro do cliente ganha o botão '+
'🔎 Buscar no Drive: em vez de colar um endereço, a pessoa procura a pasta na janela do próprio '+
'Google e escolhe. A plataforma guarda o número de identificação da pasta, que não muda quando ela '+
'é renomeada ou movida — o link colado, esse, quebra em silêncio.
'+
'A permissão pedida ao Google é a mais estreita que existe: alcança apenas a pasta que a pessoa escolher '+
'naquela janela, nunca o Drive inteiro. Nenhum arquivo é lido, baixado ou copiado — a plataforma recebe só o '+
'nome e o endereço da pasta. Cada pessoa da equipe autoriza com a conta Google dela, na primeira busca.
'+
'
'+
'
'+
''+
'
Google Cloud › APIs e serviços › Credenciais › IDs do cliente OAuth 2.0. '+
'Confira que as Origens JavaScript autorizadas incluem o endereço desta plataforma.
'+
'
'+
''+
'
Mesma tela, em Chaves de API. Três exigências, e todas as três precisam estar certas: '+
'a Google Picker API ativada em APIs e serviços › Biblioteca; a chave restrita a essa mesma '+
'Google Picker API (a Drive API sozinha não serve); e, nas restrições por site, os dois endereços — '+
'o desta plataforma e https://docs.google.com/*. Alterações na chave levam alguns minutos '+
'para o Google passar a aceitá-las.
'+
'
'+
'
'+
''+
(ligado?'':'')+
(ligado?'':'')+
'
'+
(ligado ? '
Gravado agora: '+driveResumoCred()+'
' : '')+
'
'+
(ligado ? 'Integração ligada. O botão já aparece no cadastro do cliente para quem pode cadastrar.'
: 'Enquanto os dois campos estiverem vazios, nada muda: o campo da pasta segue aceitando o link colado.')+
'
';
}
/* Mostra o começo e o fim do que está gravado, com a contagem de
caracteres: o suficiente para flagrar um valor truncado, com espaço
sobrando ou trocado por outro, sem expor o conteúdo inteiro na tela. */
function driveMascara(v){
v = String(v||'');
if(!v) return 'vazio';
const limpo = v.trim();
const aviso = (limpo.length !== v.length) ? ' (tem espaço sobrando)' : '';
const meio = limpo.length > 14 ? esc(limpo.slice(0,6))+'…'+esc(limpo.slice(-4)) : esc(limpo);
return ''+meio+' · '+limpo.length+' caracteres'+aviso;
}
function driveResumoCred(){
const d = driveCfg();
const chaveOk = /^AIza[0-9A-Za-z_\-]{30,}$/.test(String(d.apiKey||'').trim());
const idOk = /\.apps\.googleusercontent\.com$/i.test(String(d.clientId||'').trim());
return 'ID do cliente '+driveMascara(d.clientId)+(idOk?'':' (forma inesperada)')+
' Chave de API '+driveMascara(d.apiKey)+
(chaveOk?'':' (forma inesperada — a chave começa com AIza)');
}
function driveSalvarCfg(){
const c = ($('drv-client').value||'').trim(), k = ($('drv-key').value||'').trim();
if(c && !/\.apps\.googleusercontent\.com$/i.test(c)){
$('drv-hint').innerHTML = 'O ID do cliente costuma terminar em '+
'.apps.googleusercontent.com — confira se não copiou a chave secreta por engano.';
return;
}
const d = driveCfg();
d.clientId = c; d.apiKey = k;
DRIVE_TOKEN=''; DRIVE_TOKEN_ATE=0; DRIVE_TC=null; /* credenciais novas, autorização nova */
salvar(); driveBotaoNoForm(); renderCfg();
toast(driveAtivo() ? 'Busca no Drive ativada.' : 'Integração com o Drive desligada.');
}
async function driveLimparCfg(){
if(!await confirmar('Desligar a busca no Drive? O botão some do cadastro e o campo volta a aceitar só o link colado. '+
'As pastas já vinculadas continuam como estão.')) return;
const d = driveCfg(); d.clientId=''; d.apiKey='';
DRIVE_TOKEN=''; DRIVE_TOKEN_ATE=0; DRIVE_TC=null;
salvar(); driveBotaoNoForm(); renderCfg();
toast('Integração com o Drive desligada.');
}
async function driveTestar(){
const h = $('drv-hint');
h.innerHTML = 'Conferindo a chave com o Google…';
const problema = await driveConferirChave();
if(problema){ h.innerHTML = ''+problema+''; return; }
h.innerHTML = 'Chave aceita. Abrindo a janela do Google…';
try{
const pasta = await driveEscolherPasta();
h.innerHTML = pasta
? 'Funcionou. Pasta de teste lida: '+esc(pasta.nome)+'. '+
'Nada foi gravado — isto foi só uma conferência.'
: 'Janela fechada sem escolher. As credenciais estão válidas: a janela abriu.';
}catch(e){
h.innerHTML = ''+esc(driveErro(e))+'';
}
}
/* chave do DataJud: carregada do servidor, visível só para a Administração */
async function djCarregar(){
if(!$('dj-chave')) return;
try{
const j = await srvChamar('datajud-config', {});
if(j && j.ok){
$('dj-chave').value = j.chave||'';
$('dj-hint').innerHTML = j.propria
? 'Em uso: a chave gravada aqui. Apague o campo e salve para voltar à chave que vem na plataforma.'
: 'Em uso: a chave pública que já vem na plataforma. A chave atual fica publicada em datajud-wiki.cnj.jus.br › API Pública › Acesso.';
}
}catch(e){}
}
async function djSalvar(){
const v = ($('dj-chave').value||'').trim();
let j=null;
try{ j = await srvChamar('datajud-salvar', {chave:v}); }catch(e){}
if(!j || !j.ok){ toast((j&&j.msg)||'Não consegui salvar a chave.'); return; }
toast(j.propria ? 'Chave do CNJ atualizada.' : 'Voltou a usar a chave que vem na plataforma.');
djCarregar();
}
function setMarcaLogin(t){
S.cfg.loginMarca = t;
salvar(); renderCfg();
toast(t==='icone' ? 'A tela de login mostra o símbolo.'
: t==='full' ? 'A tela de login mostra a logo completa.'
: 'Carregue a imagem que deve aparecer no login.');
}
function setEscritorio(k, v){
const e=escritorio();
e[k]=String(v||'').trim();
salvar(); renderMarca(); renderCfg();
toast('Dados do escritório atualizados.');
}
function cfgAgenda(){
const u=eu(), c=agConfig(u);
const meus=(S.compromissos||[]).filter(x=>x.donoId===u.id);
const parts=(S.compromissos||[]).filter(x=>(x.participantes||[]).indexOf(u.id)>=0);
return '
Quem vê a minha agenda vale para os seus compromissos
'+
'
Os outros integrantes sempre enxergam que você está ocupado em determinado horário — isso é o que permite marcar reunião sem conflito. '+
'O que você decide aqui é se eles veem do que se trata.
'+
'
'+
'
'+
'
Marcar novos compromissos como particulares
'+
'
Deixa a caixa “Particular” já marcada ao criar. Você pode desmarcar caso a caso.
'+
'
'+
'
Registros de trabalho — prazos, audiências, perícias, atendimentos — seguem as permissões de cada perfil e '+
'não são afetados por esta escolha: eles são do escritório, não seus.
'+
'
Resumo
'+
'
'+
'
Meus compromissos
'+meus.length+'
'+
'
Particulares
'+meus.filter(x=>x.particular).length+'
'+
'
Convites recebidos
'+parts.length+'
'+
'
';
}
function setAgendaVisivel(v){ const u=eu(); u.agendaVisivel=v; salvar(); renderCfg(); renderPainel();
toast(v==='ocupado'?'A equipe passará a ver apenas “Ocupado” nos seus compromissos.':'A equipe passa a ver os detalhes dos seus compromissos.'); }
function setAgendaParticular(v){ const u=eu(); u.agendaParticular=!!v; salvar(); renderCfg(); }
function cfgNotif(){
const c = notifCfg(), u = eu();
const sw=(t,sub,on,acao,extra)=>'
'+t+'
'+
'
'+sub+'
'+(extra||'')+'
'+
'
';
return '
Como quero ser avisado vale só para você
'+
'
Dentro da plataforma, todo mundo é avisado — sempre. O sino no topo e os avisos '+
'em pop-up no canto da tela não podem ser desligados por ninguém: é o que garante que nenhum prazo passe '+
'despercebido. As preferências abaixo valem para os avisos externos. O e-mail já está em operação; '+
'push e WhatsApp ainda não.
'+
'
'+ NOTIF_CANAIS.map(x=>
'
'+x.n+
(x.fixo?' sempre ativo':(x.pronto?' ativo':' em breve'))+'
'+
'
'+x.d+'
'+
(x.fixo ? '✓ ligado'
: '')+
'
').join('')+
'
'+
(c.canais.email && !(u.email||'').trim() ? '
Cadastre um e-mail no seu registro da equipe para receber por e-mail.
Cadastre um telefone no seu registro da equipe para receber por WhatsApp.
':'')+
'
Sobre o que quero ser avisado apenas nos canais externos
'+
'
Desligar um item aqui não tira o aviso de dentro da plataforma — só evita que ele vire '+
'e-mail. Por e-mail saem hoje: tarefa atribuída a mim, compromisso em que fui incluído, '+
'menção em comentário e o resumo da manhã — sempre apenas do que é da sua responsabilidade.
'+
sw('Resumo da manhã por e-mail', 'Um e-mail só, listando o que é seu e vence hoje, amanhã e nos próximos 7 dias'+
(horaResumoEfetiva()!==(c.resumo.hora||'') ? ' — sai às '+esc(horaResumoEfetiva())+'' : ''),
c.resumo.ativo, 'setNotifResumo()',
'
'+
''+
'
'+(c.resumo.hora?'':'Em branco: vale o horário do escritório. ')+
(c.silencio.ativo?'Dentro do “não perturbe” (das '+esc(c.silencio.de)+' às '+esc(c.silencio.ate)+') o e-mail espera a retomada.':'')+
'
')+
(podeMudarSilencio()
? sw('Não perturbe', 'Segura os e-mails durante a noite e devolve no horário de retomada', c.silencio.ativo, 'setNotifSilencio()',
'
'+
''+
'
')
: '
Não perturbe '+
''+(c.silencio.ativo?'das '+esc(c.silencio.de)+' às '+esc(c.silencio.ate):'desligado')+'
'+
'
Definido pela Administração no seu cadastro. Nesse período os e-mails ficam retidos e chegam no horário de retomada.
'+
'🔒
')+
'
Agora
'+
'
Você tem '+gerarNotificacoes().length+' notificação(ões) no sino neste momento.'+
'
'+
(ehAdmin() && SRV.ativo
? '
Servidor de e-mail só a Administração
'+
'
Carregando a configuração do servidor…
'
: (ehAdmin() && !SRV.ativo
? '
Servidor de e-mail
O envio por e-mail depende do servidor '+
'(api.php). Esta cópia está rodando fora dele.
'
: ''));
}
/* ================== envio de e-mail (só Administração) ==================
A configuração vive no servidor, não no navegador: a senha da caixa de
e-mail nunca é gravada aqui e nunca volta pela rede — o servidor apenas
informa se existe uma senha guardada. */
var EMAILCFG = null;
async function emailCarregar(){
try{ const j = await srvChamar('email-config', {});
if(j && j.ok){ EMAILCFG = j; renderEmailCaixa(); return; } }catch(e){}
const el=$('email-caixa');
if(el) el.innerHTML = '
Não consegui falar com o servidor de e-mail. Tente abrir esta tela de novo.
Enviar avisos por e-mail'+(c.ativo?' ligado':' desligado')+'
'+
'
Vale para toda a equipe. Cada pessoa ainda escolhe, no próprio acesso, se quer receber.
'+
'
'+
'
'+
''+
''+
''+
'
'+
'
'+
'
'+
'
Fica guardada só no servidor, na pasta protegida. Nunca volta para o navegador.
'+
''+
''+
'
'+
'
Horário de Brasília.
'+
'
'+
'
Usado no botão dentro dos e-mails.
'+
'
'+
''+
'
Receber todo dia é o que permite perceber, na ausência do e-mail, que alguma coisa parou.
'+
'
'+
''+
'
'+
''+
''+
''+
''+
''+
'
'+
''+
'
Disparo automático
'+
'
Enquanto alguém estiver com a plataforma aberta, os avisos saem sozinhos. Para que o resumo '+
'da manhã chegue mesmo com todos os computadores desligados, crie no painel da Hostinger uma '+
'Tarefa Cron a cada 5 minutos. Em Criar nova tarefa cron, escolha Personalizado '+
'(não “PHP”) e cole exatamente este comando:
'+
'
'+esc(cmdCron)+'
'+
'
'+
''+
''+
'
'+
'
Se a hospedagem não aceitar wget, dá para usar o tipo PHP apontando para o '+
'arquivo api.php na pasta do site e acrescentando cron ao final do comando — pela linha de comando a chave não é exigida.
'+
'
'+
(EMAILCFG.ultimoResumo && EMAILCFG.ultimoResumo.dia
? 'Último resumo da manhã: '+esc(EMAILCFG.ultimoResumo.dia.split('-').reverse().join('/'))+' · '+
(EMAILCFG.ultimoResumo.n||0)+' e-mail(s) gerado(s).'
: 'O resumo da manhã ainda não rodou nenhuma vez.')+
' Agora, no relógio do servidor: '+esc(EMAILCFG.agoraBR||'—')+'.'+
(EMAILCFG.pendentes ? ' '+EMAILCFG.pendentes+' mensagem(ns) aguardando envio.' : '')+
'
'+
'';
resetarOlhos(el);
}
/* ---- diagnóstico: mostra em que ponto o e-mail parou ---- */
async function emailDiag(){
const cx=$('em-diag'); if(!cx) return;
cx.innerHTML='
';
const linhas=[];
linhas.push(item(c.ativo, 'Envio por e-mail '+(c.ativo?'ligado':'desligado'),
c.ativo ? 'A chave geral está ligada.' : 'Enquanto estiver desligado, nenhum e-mail sai. Ligue no botão acima.'));
linhas.push(item(!!c.temSenha, 'Senha da caixa '+(c.temSenha?'guardada':'não cadastrada'),
c.temSenha ? esc(c.usuario)+' em '+esc(c.host)+':'+c.porta+' ('+esc(c.seguranca).toUpperCase()+')'
: 'Sem a senha da caixa '+esc(c.usuario||'—')+' o servidor não consegue autenticar no SMTP.'));
const nunca = !ro.ultima;
linhas.push(item(nunca?false:(ro.minutos<=60), 'Rotina de envio',
nunca ? 'A rotina nunca rodou. Ela roda quando alguém usa a plataforma ou quando a Tarefa Cron da Hostinger chama o endereço abaixo.'
: 'Última passagem em '+esc(ro.ultima)+' — '+ro.minutos+' minuto(s) atrás.'));
linhas.push(item(re.hojeJaRodou, 'Resumo da manhã de hoje',
re.hojeJaRodou ? 'Já foi processado hoje: '+re.n+' e-mail(s) gerado(s).'
: 'Ainda não rodou hoje. Ele sai a partir das '+esc(c.resumoHora)+' (horário de Brasília) — agora são '+esc(sv.agora)+' no servidor'+
(sv.agora && esc(sv.agora).slice(11) < esc(c.resumoHora) ? ', ou seja, ainda é cedo' : ', então algo impediu')+'.'));
linhas.push(item(j.naFila===0, 'Fila de saída',
j.naFila ? ''+j.naFila+' mensagem(ns) esperando. Veja a lista abaixo — se houver erro repetido, é o SMTP recusando.'
: 'Vazia: nada preso esperando envio.'));
const falhas=(j.log||[]).filter(l=>!l.ok);
linhas.push(item(falhas.length===0, 'Últimas tentativas',
(j.log||[]).length===0 ? 'Nenhum envio registrado ainda — o servidor nunca chegou a tentar mandar nada.'
: (falhas.length? ''+falhas.length+' falha(s) nas últimas '+(j.log||[]).length+' tentativas. Erro mais recente: '+esc(falhas[falhas.length-1].erro||'')+''
: 'As últimas '+(j.log||[]).length+' tentativas saíram sem erro.')));
const eu2=(j.pessoas||[]).find(p=>p.email && eu() && p.email.toLowerCase()===String(eu().email||'').toLowerCase());
if(eu2){
linhas.push(item(eu2.recebe, 'O seu cadastro',
eu2.recebe ? 'Apto a receber'+(eu2.silencio?' — com silêncio das '+esc(eu2.silencio)+''+(eu2.retidoAgora?' e estamos dentro dessa janela agora':''):'')+
'. Registros seus vencendo em até 7 dias: '+eu2.pendentes7+'.'+
(eu2.resumoHoje?' O resumo de hoje já foi gerado para você.':'')
: 'Não recebe: '+esc(eu2.motivo)+'.'));
}
cx.innerHTML = '
'; }
}
function setNotifCanal(k){
if(k==='app'){ toast('Os avisos dentro da plataforma são obrigatórios para todos os perfis.'); return; }
const c=notifCfg(); c.canais[k]=!c.canais[k]; salvar(); renderCfg(); renderSino();
toast((c.canais[k]?'Ativado: ':'Desativado: ')+NOTIF_CANAIS.find(x=>x.k===k).n+'.'); }
function setNotifEvento(k){ const c=notifCfg(); c.eventos[k]=!c.eventos[k]; salvar(); renderCfg(); renderSino(); }
function setNotifAntec(v){ const c=notifCfg(); c.antecedencia=+v; salvar(); renderCfg(); renderSino(); }
function setNotifResumo(){
const c=notifCfg(); c.resumo.ativo=!c.resumo.ativo; salvar(); renderCfg();
toast(c.resumo.ativo ? 'Resumo da manhã ligado — sai às '+horaResumoEfetiva()+'.'
: 'Resumo da manhã desligado. Os avisos de atribuição e menção continuam.');
}
/* A pessoa escolhe a hora, mas não pode escolher receber dentro do próprio
silêncio: nesse caso a plataforma empurra para o fim da janela. */
function horaResumoEfetiva(){
const c=notifCfg(), padrao=(EMAILCFG && EMAILCFG.cfg && EMAILCFG.cfg.resumoHora) || '08:30';
let h = c.resumo.hora || padrao;
if(c.silencio && c.silencio.ativo){
const m=minutos(h), de=minutos(c.silencio.de), ate=minutos(c.silencio.ate);
if(m!=null && de!=null && ate!=null){
const dentro = de=de && m=de || mTema'+
'
'+
['light','dark'].map(t=>'').join('')+'
'+
'
Tamanho da fonte '+z+'%
'+
'
'+
[90,100,110,125,150].map(v=>'').join('')+'
'+
'
A escala vale para toda a plataforma e fica gravada neste navegador.
'+
'
Menu lateral
'+
'
'+
''+
''+
'
'+
(ehAdmin()? '
A logo e os dados do escritório ficam em Configuração › Escritório.
' : '');
}
function cfgDados(){
return '
Dados da plataforma
'+
'
'+(SRV.ativo
? 'Os dados ficam no servidor do escritório e aparecem igual em qualquer computador ou celular que entrar por este endereço. Ainda assim, baixe um backup de tempos em tempos e guarde fora do servidor.'
: 'Tudo fica gravado neste navegador. Exporte com frequência para não depender de um único computador.')+'
'+
'
'+
''+
''+
''+
''+
''+
'
'+
'
Os dois arquivos levam equipe, clientes, registros, agenda e os modelos do '+
'gerador de documentos. A diferença é a base da Gestão Financeira — recebíveis, despesas, baixas e fechamentos: '+
'o primeiro leva, o segundo não. Use o completo como backup de rotina; o outro serve para passar a plataforma adiante '+
'sem expor a movimentação financeira do escritório.
'+
'
O terceiro botão gera um arquivo só com a Gestão Financeira — útil para alimentar apenas '+
'aquele menu, em outro computador ou depois de uma limpeza, sem mexer em equipe, clientes e registros.
'+
'
O mesmo botão Restaurar aceita os três arquivos e reconhece sozinho o que veio: um backup '+
'da plataforma, um backup só do financeiro ou a base exportada pelo próprio módulo financeiro. Antes de substituir '+
'qualquer coisa, ele diz o que entra e o que sai.
'+
'
Exportações avulsas
'+
'
'+
''+
'
'+
'
Resumo
'+
'
'+
'
Integrantes
'+S.membros.length+'
'+
'
Clientes
'+S.clientes.length+'
'+
'
Registros
'+S.tarefas.length+'
'+
(function(){ const g=finLerEstado();
const n = g ? ((g.recebiveis||[]).length+(g.finalizados||[]).length+(g.despesas||[]).length) : null;
return '
Gestão Financeira
'+
(n==null?'—':n)+'
'+
(n==null?'abra o menu financeiro uma vez para contar':'lançamentos guardados')+'
Um lançamento que desapareceu quase sempre está numa destas cópias. '+
'Você pode trazer de volta só o que está faltando, sem desfazer nada do que foi feito depois.
'+
'
'+
''+
'
'
: '')+
cfgLimpeza()+
'
Zona de risco
'+
'';
}
function carregarLogo(e,campo,max){
const f=e.target.files[0]; e.target.value=''; if(!f) return;
if(f.size>3*1024*1024){ toast('Arquivo acima de 3 MB. Escolha um menor.'); return; }
const r=new FileReader();
r.onload=()=>{ redimensionarPng(r.result,max,d=>{ S.cfg[campo]=d; salvar(); renderLogo(); renderCfg(); toast('Logo atualizada.'); }); };
r.readAsDataURL(f);
}
function redimensionarPng(dataUrl,max,cb){
const img=new Image();
img.onload=()=>{ const s=Math.min(1,max/Math.max(img.width,img.height));
const c=document.createElement('canvas'); c.width=Math.round(img.width*s); c.height=Math.round(img.height*s);
c.getContext('2d').drawImage(img,0,0,c.width,c.height); cb(c.toDataURL('image/png')); };
img.onerror=()=>cb(dataUrl); img.src=dataUrl;
}
function setTema(t){ S.cfg.tema=t; salvar(); aplicarTema(); renderCfg(); }
function ciclarTema(){ setTema(S.cfg.tema==='dark'?'light':'dark'); }
function aplicarTema(){ document.body.classList.toggle('dark', S.cfg.tema==='dark');
if(typeof sincronizarTemaFin==='function') setTimeout(sincronizarTemaFin,0);
$('btn-tema').textContent = S.cfg.tema==='dark'?'☀':'🌙';
$('btn-tema').title = S.cfg.tema==='dark'?'Voltar ao modo claro':'Alternar para o modo escuro'; }
function setZoom(v){ S.cfg.zoom=v; salvar(); aplicarZoom(); renderCfg(); }
function aplicarZoom(){ document.documentElement.style.zoom = (S.cfg.zoom/100); }
/* ---- backup ---- */
/* O visualizador de artefatos bloqueia downloads iniciados pela própria página.
Quando disponível, usamos a capacidade "downloads" (o visitante confirma o
salvamento); fora dela, voltamos ao caminho clássico do link temporário. */
let _DLns=null, _DLbuscado=false;
async function _dlNS(){
if(_DLbuscado) return _DLns;
_DLbuscado=true;
try{ _DLns = (window.claude && typeof claude.use==='function') ? await claude.use('downloads') : null; }
catch(e){ _DLns=null; }
return _DLns;
}
async function baixar(nome,conteudo,tipo,opc){
opc = opc || {};
const ns = await _dlNS();
if(ns){
try{ await ns.save({filename:nome, data:conteudo}); return true; }
catch(err){
const c = err && err.code;
if(c==='declined') return false;
if((c==='extension_not_enabled' || c==='rejected_extension') && !opc.semTxt){
try{ await ns.save({filename:nome.replace(/\.[^.]+$/,'')+'.txt', data:conteudo});
toast('Salvo como .txt — renomeie a extensão para abrir no programa de sempre.'); return true; }
catch(e2){}
}
if(c==='too_large'){ toast('Arquivo grande demais para salvar por aqui (limite de 16 MB).'); return false; }
toast('Não consegui salvar o arquivo neste ambiente. Use "Copiar conteúdo".');
return false;
}
}
try{
const b=new Blob([conteudo],{type:tipo}); const u=URL.createObjectURL(b);
const a=document.createElement('a'); a.href=u; a.download=nome; a.click(); setTimeout(()=>URL.revokeObjectURL(u),2000);
return true;
}catch(e){ return false; }
}
/* Converte o anexo guardado como data: URL em arquivo, sem depender de
rede. Antes isto era feito com fetch(), que o navegador trata como
pedido externo e a regra de segurança do servidor barra — o anexo
existia, estava íntegro, e mesmo assim não baixava. */
function dataUrlParaBlob(url){
/* Tudo antes da primeira vírgula é o cabeçalho; o resto é o arquivo.
Separar assim evita a armadilha de tentar isolar ";base64" com um
padrão guloso, que o engolia junto com os demais parâmetros e fazia
o conteúdo ser tratado como texto — arquivo salvo e corrompido. */
const txt = String(url||'');
const virgula = txt.indexOf(',');
if(!/^data:/i.test(txt) || virgula < 0) return null;
const cabecalho = txt.slice(5, virgula);
const corpo = txt.slice(virgula + 1);
const ehBase64 = /(^|;)\s*base64\s*$/i.test(cabecalho);
const tipo = (cabecalho.split(';')[0] || '').trim() || 'application/octet-stream';
if(ehBase64){
const bin = atob(corpo.replace(/\s/g,''));
const buf = new Uint8Array(bin.length);
for(let i=0;is+((a.modelos||[]).length),0) : 0
};
}
function inventarioAtual(){
const fin = finLerEstado();
return {
membros:(S.membros||[]).length, clientes:(S.clientes||[]).length,
tarefas:(S.tarefas||[]).length, compromissos:(S.compromissos||[]).length,
lancamentos: contarFin(fin),
acoes: (DOCS && DOCS.acoes ? DOCS.acoes.length : 0)
};
}
/* ---------------------- restauração ---------------------- */
async function importarFinanceiro(g, quando){
const n = contarFin(g), atual = finLerEstado(), na = contarFin(atual);
if(!await confirmar('Substituir a base da Gestão Financeira por este arquivo'+(quando?' de '+fmtDH(quando):'')+'?\n\n'+
'Entra: '+n+' lançamento(s) do arquivo.\n'+
'Sai: '+(atual? na+' lançamento(s) que estão neste navegador.' : 'nada — não há base financeira aqui ainda.')+'\n'+
'Equipe, clientes, registros e modelos de documentos não são tocados.')) return false;
SOMBRA_FIN = null;
if(!finGravarEstado(g, true)){ toast('Não consegui gravar a base financeira neste navegador.'); return false; }
if(finPodeSincronizar()) srvAgendar('financeiro', true);
if(UI.painel==='financeiro'){ if(!finAtualizarFrame()) montarFinanceiro(true); } else FIN_PRONTO=false;
redesenhar();
const conferido = contarFin(finLerEstado());
toast(conferido===n ? 'Gestão Financeira restaurada — '+n+' lançamento(s).'
: 'Atenção: entraram '+conferido+' de '+n+' lançamento(s). Gere um backup e confira.');
return true;
}
function importarTudo(e){
const f=e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onerror = ()=> toast('Não consegui ler o arquivo. Tente de novo.');
r.onload=async ()=>{
let p=null;
try{ p=JSON.parse(r.result); }catch(x){ toast('Arquivo inválido: não é um JSON legível.'); return; }
const tipo = tipoDoBackup(p);
if(!tipo){ toast('Arquivo inválido: não parece um backup desta plataforma nem da Gestão Financeira.'); return; }
/* só financeiro: caminho próprio, não encosta em equipe nem em registros */
if(tipo==='financeiro'){ await importarFinanceiro(financeiroDoArquivo(p), dataDoArquivo(p)); return; }
const base = plataformaDoArquivo(p);
const fin = financeiroDoArquivo(p);
const mod = modelosDoArquivo(p);
const inv = inventarioDoBackup(p), hoje = inventarioAtual();
const quando = dataDoArquivo(p);
const linha = (rot, entra, sai) => rot+': '+entra+(sai!=null?' (hoje: '+sai+')':'');
if(!await confirmar(
'Restaurar este backup'+(quando?' de '+fmtDH(quando):'')+
(p.por?', gerado por '+p.por:'')+'?\n\n'+
'ENTRA DO ARQUIVO\n'+
linha('Integrantes', inv.membros, hoje.membros)+'\n'+
linha('Clientes', inv.clientes, hoje.clientes)+'\n'+
linha('Registros', inv.tarefas, hoje.tarefas)+'\n'+
linha('Compromissos', inv.compromissos, hoje.compromissos)+'\n'+
'Gestão Financeira: '+(inv.financeiro ? inv.lancamentos+' lançamento(s) (hoje: '+hoje.lancamentos+')'
: 'não vem neste arquivo — a atual fica como está')+'\n'+
'Modelos de documentos: '+(inv.modelos ? inv.acoes+' ação(ões), '+inv.documentos+' documento(s)'
: 'não vêm neste arquivo — os atuais ficam como estão')+'\n\n'+
'O que entrar substitui o que está aqui hoje.')) return;
/* --- grava --- */
SOMBRA_EST = null; SOMBRA_FIN = null;
S = migrar(JSON.parse(JSON.stringify(base)));
if(SRV.ativo && SRV.membroId) S.usuario = SRV.membroId; /* quem está logado é deste aparelho */
if(SRV.ativo && SRV.token) SRV.forcar.estado = true;
salvar();
let okMod = true;
if(mod){ try{ DOCS = docsMigrar(JSON.parse(JSON.stringify(mod))); okMod = docsSalvar(); }
catch(x){ okMod = false; } }
let okFin = true;
if(fin){ okFin = finGravarEstado(fin, true);
if(okFin && finPodeSincronizar()) srvAgendar('financeiro', true);
if(UI.painel==='financeiro'){ if(!finAtualizarFrame()) montarFinanceiro(true); } else FIN_PRONTO=false; }
UI.ordem = S.cfg.ordem || 'data-asc';
aplicarTema(); aplicarZoom(); aplicarSide(); redesenhar();
try{ verificarNotas(); }catch(x){}
/* --- confere o que realmente entrou, item por item --- */
const depois = inventarioAtual(), faltou = [];
if(depois.membros !== inv.membros) faltou.push('integrantes ('+depois.membros+' de '+inv.membros+')');
if(depois.clientes !== inv.clientes) faltou.push('clientes ('+depois.clientes+' de '+inv.clientes+')');
if(depois.tarefas !== inv.tarefas) faltou.push('registros ('+depois.tarefas+' de '+inv.tarefas+')');
if(depois.compromissos !== inv.compromissos) faltou.push('compromissos ('+depois.compromissos+' de '+inv.compromissos+')');
if(fin && (!okFin || depois.lancamentos !== inv.lancamentos))
faltou.push('lançamentos financeiros ('+depois.lancamentos+' de '+inv.lancamentos+')');
if(mod && (!okMod || depois.acoes !== inv.acoes))
faltou.push('modelos de documentos ('+depois.acoes+' de '+inv.acoes+' ação(ões))');
if(faltou.length){
toast('Restauração incompleta — verifique: '+faltou.join('; '));
setTimeout(()=>confirmar('A restauração não entrou por inteiro.\n\nNão conferem: '+faltou.join('\n')+
'\n\nGuarde o arquivo de backup e avise a Administração antes de continuar trabalhando.'), 400);
return;
}
const partes = ['equipe, clientes e registros'];
if(fin) partes.push(inv.lancamentos+' lançamento(s) da Gestão Financeira');
if(mod) partes.push(inv.acoes+' ação(ões) de modelos');
toast('Backup restaurado por completo — '+partes.join(', ')+'.');
};
r.readAsText(f); e.target.value='';
}
/* Ata aberta em branco e fechada sem salvar não deixa registro vazio para trás. */
function descartarAtaVazia(){
const id = UI.ataNovaId; if(!id) return;
UI.ataNovaId = null;
const a = ataPorId(id); if(!a) return;
if((a.titulo||'').trim() || edTexto(a.texto) || edTexto(a.pauta)) return;
const i = atas().findIndex(x=>x.id===id);
if(i>=0){ atas().splice(i,1); salvar(); redesenhar(); }
}
function importarJSON(e){
const f=e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=async ()=>{ try{ const s=JSON.parse(r.result); if(!s.membros) throw 0;
if(!await confirmar('Substituir todos os dados atuais pelo backup?')) return;
S=migrar(s); salvar(); UI.ordem = S.cfg.ordem || 'data-asc';
aplicarTema(); aplicarZoom(); aplicarSide(); redesenhar(); toast('Backup importado.');
}catch(x){ toast('Arquivo inválido.'); } };
r.readAsText(f); e.target.value='';
}
function exportarCSV(){
const cab=['Tipo','Descrição','Clientes','Áreas','Processo','Data','Início','Término','Status','Prioridade','Ação','Responsáveis','Subtarefas','Link','Criado por','Criado em','Concluído em','Alterações'];
const linhas=S.tarefas.map(t=>[TIPOS[t.tipo].kind,t.descricao,
(t.clientes||[]).map(id=>{const c=cliente(id);return c?c.nome:'';}).join(' | '),
(t.areas||[]).join(' | '), t.processo||'', fmtD(t.data), t.hora||'', t.horaFim||'', t.status, t.prioridade||'Média', t.acao, nomesResp(t).join(' | '),
(t.checklist&&t.checklist.ativo)? (t.checklist.itens.filter(i=>i.ok).length+'/'+t.checklist.itens.length) : '',
t.link||'', t.criadoPor||'', fmtDH(t.criadoEm), t.concluidoEm?fmtDH(t.concluidoEm):'', (t.historico||[]).length]);
const csv=[cab].concat(linhas).map(l=>l.map(c=>'"'+String(c==null?'':c).replace(/"/g,'""')+'"').join(';')).join('\r\n');
baixar('registros-'+new Date().toISOString().slice(0,10)+'.csv',''+csv,'text/csv;charset=utf-8'); toast('CSV gerado.');
}
async function zerar(){ if(!await confirmar('Isso apaga equipe, clientes e todos os registros. Continuar?')) return;
if(!await confirmar('Tem certeza mesmo? Não há como desfazer.')) return;
localStorage.removeItem(KEY); SOMBRA_EST=null; S=seed();
if(SRV.ativo && SRV.token) SRV.forcar.estado = true;
salvar(); fechar('ov-cfg'); aplicarTema(); aplicarZoom(); redesenhar(); toast('Plataforma zerada.'); }
/* =========================================================
MEMBROS
========================================================= */
function sincronizarAssociadosManual(){
const r = sincronizarAssociados(true);
redesenhar(); renderCfg();
if(r.lidos===0){ toast('Abra a Gestão Financeira uma vez para que a lista fique disponível.'); return; }
if(!r.enviados && !r.importados){ toast('Tudo já está espelhado: '+r.lidos+' cadastro(s) na Gestão Financeira.'); return; }
const partes=[];
if(r.enviados) partes.push(r.enviados+' enviado(s) para a Gestão Financeira');
if(r.importados) partes.push(r.importados+' trazido(s) para a equipe');
toast('Associados sincronizados: '+partes.join(' · ')+'.');
}
function editarMembro(id){
const m = id? membro(id) : null;
if(id && !pode('gerir_equipe') && id!==S.usuario){ toast('Sem permissão.'); return; }
if(!id && !pode('gerir_equipe')){ toast('Sem permissão.'); return; }
UI.membroEdit = id; UI.fotoTmp = m? (m.foto||'') : '';
$('mb-titulo').textContent = m? m.nome : 'Novo integrante';
$('mb-funcao').innerHTML = Object.keys(FUNCOES).map(f=>'').join('');
$('mb-uf').innerHTML = ''+UFS.map(u=>'').join('');
['nome','oab','cpf','pix','email','tel','email2','tel2','nasc'].forEach(k=>$('mb-'+k).value = m? (m[k]||'') : '');
$('mb-status').value = m? m.status : 'ativo';
$('mb-escopo').value = m? m.escopo : FUNCOES[Object.keys(FUNCOES)[0]].escopo;
if(!m) aplicarPreset(); else { renderPerms(m.perms); notaFuncao(); }
const sil = (m && m.notif && m.notif.silencio) ? m.notif.silencio : {ativo:false, de:'20:00', ate:'07:00'};
$('mb-dnd').value = sil.ativo ? '1' : '0';
$('mb-dnd-de').value = sil.de || '20:00';
$('mb-dnd-ate').value = sil.ate || '07:00';
$('mb-del').style.display = podeExcluirMembro(m)?'':'none';
const ro = !pode('gerir_equipe');
document.querySelectorAll('#mb-perms input,#mb-escopo,#mb-funcao,#mb-status,#mb-dnd,#mb-dnd-de,#mb-dnd-ate').forEach(el=>el.disabled=ro);
travarFinanceiro(); /* a trava por função vale mesmo para quem administra a equipe */
$('mb-err').classList.remove('on');
fotoPrev();
abrir('ov-membro');
}
function renderPerms(perms){
const item = k=>{ const p=PERMS.find(x=>x.k===k);
return ''; };
$('mb-perms').innerHTML = GRUPOS_PERM.map((g,i)=>
'
'+
'
'+esc(g.n)+''+
''+
''+
'
'+
'
'+g.ks.map(item).join('')+'
'+
'
').join('');
contarPerms();
travarFinanceiro();
if(!pode('gerir_equipe')) document.querySelectorAll('#mb-perms input,#mb-perms button').forEach(el=>el.disabled=true);
}
/* Ver Financeiro só faz sentido para Administração e sócios — a caixa fica
travada nas demais funções, para não prometer um acesso que não existe. */
function travarFinanceiro(){
const f = $('mb-funcao') ? $('mb-funcao').value : '';
const liberado = FUNCOES_FINANCEIRO.indexOf(f)>=0;
const el = document.querySelector('#mb-perms input[data-p="ver_financeiro"]');
if(!el) return;
if(!liberado) el.checked=false;
el.disabled = !liberado;
const rot = el.closest('label');
if(rot){
rot.style.opacity = liberado?'':'.5';
rot.title = liberado?'' : 'Disponível apenas para Administração e Advogado(a) sócio(a).';
const pequeno = rot.querySelector('small');
if(pequeno) pequeno.textContent = liberado
? 'Honorários, RPV, precatórios e contas'
: 'restrito à Administração e aos sócios';
}
contarPerms();
}
function marcarGrupo(i,val){
if(!pode('gerir_equipe')) return;
GRUPOS_PERM[i].ks.forEach(k=>{ const el=document.querySelector('#mb-perms input[data-p="'+k+'"]'); if(el && !el.disabled) el.checked=val; });
travarFinanceiro();
}
function contarPerms(){
GRUPOS_PERM.forEach((g,i)=>{
const n = g.ks.filter(k=>{ const el=document.querySelector('#mb-perms input[data-p="'+k+'"]'); return el&&el.checked; }).length;
const el = document.querySelector('#mb-perms .pgc[data-c="'+i+'"]');
if(el){ el.textContent = n+' de '+g.ks.length; el.className = 'pgc'+(n?'':' zero'); }
});
}
function aplicarPreset(){
const f=$('mb-funcao').value, P=FUNCOES[f];
if(!P) return;
renderPerms(P.perms); $('mb-escopo').value=P.escopo;
travarFinanceiro(); notaFuncao();
}
function notaFuncao(){
const f=$('mb-funcao').value;
const fixa = FUNCOES_FIXAS.indexOf(f)>=0;
$('mb-preset-nota').innerHTML = fixa
? (f==='Administração'
? 'Administração é a única função com acesso geral: enxerga todos os painéis, administra a equipe e é a única que pode excluir clientes e registros concluídos.'
: 'Advogado(a) sócio(a) recebe tudo, menos administrar a equipe e excluir clientes e registros já concluídos — isso segue exclusivo da Administração.')
: ''+esc(f)+' não vem com permissões prontas. Marque abaixo, item a item, exatamente o que esta pessoa poderá ver e fazer.';
}
function carregarFoto(e){
const f=e.target.files[0]; if(!f) return;
if(f.size>2*1024*1024){ toast('Foto acima de 2 MB. Escolha uma menor.'); return; }
const r=new FileReader(); r.onload=()=>{ redimensionar(r.result, 220, d=>{ UI.fotoTmp=d; fotoPrev(); }); }; r.readAsDataURL(f);
e.target.value='';
}
function redimensionar(dataUrl,max,cb){
const img=new Image();
img.onload=()=>{ const s=Math.min(1,max/Math.max(img.width,img.height));
const c=document.createElement('canvas'); c.width=Math.round(img.width*s); c.height=Math.round(img.height*s);
c.getContext('2d').drawImage(img,0,0,c.width,c.height); cb(c.toDataURL('image/jpeg',.82)); };
img.onerror=()=>cb(dataUrl); img.src=dataUrl;
}
function limparFoto(){ UI.fotoTmp=''; fotoPrev(); }
function fotoPrev(){
const el=$('mb-foto-prev'); const n=$('mb-nome').value;
if(UI.fotoTmp){ el.style.backgroundImage='url('+UI.fotoTmp+')'; el.textContent=''; }
else { el.style.backgroundImage=''; el.textContent = iniciais(n||'?'); }
}
function coletarMembro(){
return {
nome:($('mb-nome').value||'').trim(), funcao:$('mb-funcao').value, cpf:$('mb-cpf').value,
status:$('mb-status').value, oab:($('mb-oab').value||'').trim(), uf:$('mb-uf').value,
pix:($('mb-pix').value||'').trim(), email:($('mb-email').value||'').trim(), nasc:$('mb-nasc').value||'',
tel:$('mb-tel').value, email2:($('mb-email2').value||'').trim(), tel2:$('mb-tel2').value,
escopo:$('mb-escopo').value, foto:UI.fotoTmp||'',
silencio:{ativo:$('mb-dnd').value==='1', de:$('mb-dnd-de').value||'20:00', ate:$('mb-dnd-ate').value||'07:00'},
perms:(()=>{const o={};document.querySelectorAll('#mb-perms input').forEach(i=>o[i.dataset.p]=i.checked);return o;})()
};
}
function validarMembro(o){
const e=[];
document.querySelector('#ov-membro [id="mb-nome"]').parentNode.classList.toggle('err',!o.nome);
if(!o.nome) e.push('Nome');
document.querySelector('#ov-membro [id="mb-email"]').parentNode.classList.toggle('err',!emailOk(o.email));
if(!emailOk(o.email)) e.push('E-mail principal');
if(o.oab && !o.uf) e.push('UF da OAB');
return e;
}
async function salvarMembro(silencioso){
const o=coletarMembro(), err=validarMembro(o);
if(err.length){ const b=$('mb-err'); b.innerHTML='Revise: '+err.map(esc).join(', ')+'.'; b.classList.add('on'); return null; }
const vePainel = PERMS.filter(x=>x.k.indexOf('ver_')===0 && x.k!=='ver_equipe').some(x=>o.perms[x.k]);
if(!vePainel && !await confirmar('Nenhum painel foi liberado para '+o.nome+'. A pessoa vai abrir a plataforma sem nada para ver.\n\nSalvar assim mesmo?')) return null;
/* o silêncio mora dentro das preferências de notificação da pessoa */
const sil = o.silencio; delete o.silencio;
let m;
if(UI.membroEdit){ m=membro(UI.membroEdit); Object.assign(m,o); }
else { m=Object.assign({id:novoId('membro'),criadoEm:agoraISO()},o); S.membros.push(m); UI.membroEdit=m.id; }
if(!m.notif) m.notif = NOTIF_PADRAO();
m.notif.silencio = {ativo:!!sil.ativo, de:sil.de, ate:sil.ate};
salvar();
sincronizarAssociados(true);
if(!silencioso){ fechar('ov-membro'); redesenhar(); toast('Integrante salvo.'); }
return m;
}
/* Excluir integrante é ato da Administração — e só dela: nem o sócio, nem quem
recebeu a permissão de gerir equipe tira alguém do cadastro. Ninguém se exclui,
e a última Administração ativa não sai, para o escritório não ficar sem dono. */
function ehUltimaAdmin(m){
if(!m || m.funcao!=='Administração') return false;
return S.membros.filter(x=>x.funcao==='Administração' && x.status!=='inativo' && x.id!==m.id).length===0;
}
function podeExcluirMembro(m){
return !!m && ehAdmin() && m.id!==S.usuario && !ehUltimaAdmin(m);
}
async function excluirMembro(id){
id = id || UI.membroEdit;
const m = membro(+id); if(!m) return;
if(!ehAdmin()){ toast('Somente a Administração exclui integrantes da equipe.'); return; }
if(m.id===S.usuario){ toast('Você não pode excluir o próprio acesso. Peça a outra Administração.'); return; }
if(ehUltimaAdmin(m)){ toast('Esta é a única Administração ativa — cadastre outra antes de excluir.'); return; }
const abertos = S.tarefas.filter(t=>t.status!==CONCLUIDA && (t.responsaveis||[]).indexOf(m.id)>=0);
const compromissos = (S.compromissos||[]).filter(c=>c.donoId===m.id);
const linhas = ['Excluir '+m.nome+' da equipe?', '', 'O que acontece:'];
if(abertos.length) linhas.push('• '+abertos.length+' registro(s) em aberto sob responsabilidade dele(a) passam para você');
if(compromissos.length) linhas.push('• '+compromissos.length+' compromisso(s) da agenda dele(a) serão apagados');
linhas.push('• o histórico dos registros continua mostrando o nome de quem fez cada coisa');
linhas.push('• o acesso é encerrado: essa pessoa deixa de conseguir entrar na plataforma');
linhas.push('', 'Se a saída for temporária, o melhor é marcar como Inativo em vez de excluir.');
if(!await confirmar(linhas.join('\n'))) return;
const meuId = S.usuario;
abertos.forEach(t=>{
t.responsaveis = (t.responsaveis||[]).filter(x=>x!==m.id);
if(t.responsaveis.indexOf(meuId)<0) t.responsaveis.push(meuId);
hist(t, 'Responsabilidade transferida de '+m.nome+' para '+eu().nome+' — integrante excluído da equipe.');
});
S.tarefas.forEach(t=>{
t.responsaveis = (t.responsaveis||[]).filter(x=>x!==m.id);
(t.comentarios||[]).forEach(c=>{ if(Array.isArray(c.mencoes)) c.mencoes=c.mencoes.filter(x=>x!==m.id); });
});
S.compromissos = (S.compromissos||[]).filter(c=>c.donoId!==m.id);
S.compromissos.forEach(c=>{ if(Array.isArray(c.participantes)) c.participantes=c.participantes.filter(x=>x!==m.id); });
S.membros = S.membros.filter(x=>x.id!==m.id);
S.rascunhos = (S.rascunhos||[]).filter(r=>r.autorId!==m.id);
salvar(); fechar('ov-membro'); redesenhar(); renderCfg();
toast(m.nome+' foi excluído(a) da equipe'+(abertos.length?' — '+abertos.length+' registro(s) vieram para você.':'.'));
}
/* =========================================================
CLIENTES
========================================================= */
function mascDoc(el){
let v=(el.value||'').replace(/\D/g,'').slice(0,14);
const h=$('cl-doc-hint');
if(v.length<=11){
el.value = v.replace(/(\d{3})(\d)/,'$1.$2').replace(/(\d{3})(\d)/,'$1.$2').replace(/(\d{3})(\d{1,2})$/,'$1-$2');
if(h) h.textContent = v.length===11 ? 'CPF — 11 dígitos.' : 'O formato é reconhecido pela quantidade de dígitos.';
} else {
el.value = v.replace(/^(\d{2})(\d)/,'$1.$2').replace(/^(\d{2})\.(\d{3})(\d)/,'$1.$2.$3')
.replace(/\.(\d{3})(\d)/,'.$1/$2').replace(/(\d{4})(\d)/,'$1-$2');
if(h) h.textContent = 'CNPJ — 14 dígitos.';
}
}
function tipoDoc(v){ const n=String(v||'').replace(/\D/g,'').length; return n===14?'CNPJ':(n===11?'CPF':''); }
function sugereEmail(){
const el=$('cl-email'), d=$('cl-email-drop'), v=el.value||'';
const at=v.indexOf('@');
if(at<0){ d.classList.remove('on'); return; }
const usuario=v.slice(0,at), dominio=v.slice(at+1).toLowerCase();
const achados = EMAIL_DOMS.filter(x=>x.indexOf(dominio)===0 && x!==dominio).slice(0,6);
if(!usuario || !achados.length){ d.classList.remove('on'); return; }
d.innerHTML = achados.map(x=>'').join('');
d.classList.add('on');
}
function usarEmail(v){ $('cl-email').value=v; $('cl-email-drop').classList.remove('on'); $('cl-email').focus(); }
function checaDrive(){
const sem = $('cl-semdrive') && $('cl-semdrive').checked;
const v = ($('cl-drive').value||'').trim();
const card=$('cl-drive-card'), vazio=$('cl-drive-vazio'), h=$('cl-drive-hint');
const nome=$('cl-drive-nome'), sub=$('cl-drive-sub');
const trocar=$('cl-drive-trocar'), verlink=$('cl-drive-verlink'), lb=$('cl-drive-linkbox');
const vinc = driveEscolhaValida();
/* "Não há": some tudo, e o bloco deixa de cobrar qualquer coisa */
if(sem){
card.classList.remove('on'); vazio.classList.remove('on');
if(trocar) trocar.style.display='none';
if(verlink) verlink.style.display='none';
if(lb) lb.style.display='none';
driveBotaoNoForm();
return;
}
if(verlink) verlink.style.display='';
driveBotaoNoForm();
if(v){
/* a pasta em destaque: o nome dela no Drive é o rótulo; sem vínculo,
usa o nome do cliente, que é como a equipe a reconhece */
const rotulo = (vinc && vinc.nome) || ($('cl-nome') && ($('cl-nome').value||'').trim()) || 'Pasta do cliente';
nome.textContent = rotulo;
sub.textContent = vinc
? 'abrir no Google Drive · vínculo resiste a renomear e mover'
: 'abrir no Google Drive';
card.href = linkHref(v) || '#';
card.classList.add('on'); vazio.classList.remove('on');
if(trocar) trocar.style.display='';
} else {
card.classList.remove('on'); vazio.classList.add('on');
if(trocar) trocar.style.display='none';
}
/* o link fica discreto: só a dica muda de tom conforme o que há ali */
if(!h) return;
if(!v){
h.textContent = driveAtivo()
? 'Use "Buscar no Drive" para escolher a pasta, ou cole o link aqui se preferir.'
: 'Cole aqui o link da pasta do cliente no Google Drive.';
h.style.color=''; return;
}
if(vinc){
h.innerHTML = 'Preenchido pela busca no Drive. Pode ser editado ou apagado à mão — trocar o endereço desfaz o vínculo.';
h.style.color='var(--green)'; return;
}
const ok = /^(https?:\/\/)?(drive|docs)\.google\.com\//i.test(v);
h.textContent = ok ? 'Link do Google Drive reconhecido.'
: 'Não parece um endereço do Google Drive — confira antes de salvar.';
h.style.color = ok ? 'var(--green)' : 'var(--amber)';
}
/* Mostra ou esconde a linha do link. Ele existe para ser editado ou
apagado quando preciso, mas não precisa ocupar a tela o tempo todo. */
function driveVerLink(){
const lb=$('cl-drive-linkbox'), b=$('cl-drive-verlink');
if(!lb) return;
const aberto = lb.style.display!=='none';
lb.style.display = aberto ? 'none' : '';
if(b) b.textContent = aberto ? 'Ver o link' : 'Ocultar o link';
if(!aberto && $('cl-drive')) $('cl-drive').focus();
}
function driveLinkAberto(forcar){
const lb=$('cl-drive-linkbox'), b=$('cl-drive-verlink');
if(!lb) return;
lb.style.display = forcar ? '' : 'none';
if(b) b.textContent = forcar ? 'Ocultar o link' : 'Ver o link';
}
async function driveDesvincular(){
if(!await confirmar('Remover a pasta deste cadastro? O link é apagado e o vínculo com o Drive se desfaz. '+
'A pasta continua intacta no Google Drive.')) return;
UI.driveEscolha = null;
if($('cl-drive')) $('cl-drive').value='';
checaDrive();
toast('Pasta removida do cadastro.');
}
/* =========================================================
O LINK DO REGISTRO, EM DESTAQUE
O campo aceita qualquer endereço, mas quase sempre é uma
pasta ou um arquivo no Drive — e um endereço comprido não
diz nada a quem abre a ficha. Reconhecido o tipo, ele vira
um cartão com nome e ícone, igual ao da pasta do cliente.
========================================================= */
const LINK_TIPOS = [
{re:/drive\.google\.com\/drive\/(u\/\d+\/)?folders\//i, ic:'📁', nome:'Pasta no Google Drive', drive:true},
{re:/drive\.google\.com\/file\//i, ic:'📄', nome:'Arquivo no Google Drive', drive:true},
{re:/drive\.google\.com/i, ic:'📁', nome:'Google Drive', drive:true},
{re:/docs\.google\.com\/document/i, ic:'📝', nome:'Documento do Google', drive:true},
{re:/docs\.google\.com\/spreadsheets/i, ic:'📊', nome:'Planilha do Google', drive:true},
{re:/docs\.google\.com\/presentation/i, ic:'📽', nome:'Apresentação do Google', drive:true},
{re:/docs\.google\.com\/forms/i, ic:'🗒', nome:'Formulário do Google', drive:true},
{re:/docs\.google\.com/i, ic:'📄', nome:'Documento do Google', drive:true},
{re:/meet\.google\.com/i, ic:'🎥', nome:'Sala do Google Meet'},
{re:/(^|\.)pje\.|\/pje\/|eproc|esaj|projudi|\.jus\.br/i, ic:'⚖', nome:'Processo no sistema do tribunal'},
{re:/gov\.br/i, ic:'🏛', nome:'Serviço do Gov.br'},
{re:/wa\.me|whatsapp/i, ic:'💬', nome:'Conversa no WhatsApp'}
];
function linkTipo(v){
const t = String(v||'').trim();
if(!t) return null;
for(const x of LINK_TIPOS) if(x.re.test(t)) return x;
return {ic:'🔗', nome:'Link externo'};
}
/* O trecho legível do endereço, para dizer de onde é sem despejar a URL. */
function linkDominio(v){
const m = /^(?:https?:\/\/)?(?:www\.)?([^\/?#]+)/i.exec(String(v||'').trim());
return m ? m[1] : '';
}
function checaLinkTarefa(){
const inp=$('t-link'); if(!inp) return;
const v=(inp.value||'').trim();
const card=$('t-link-card'), copiar=$('t-link-copiar');
const tipo = v ? linkTipo(v) : null;
const href = v ? linkHref(v) : '';
if(copiar) copiar.style.display = v ? '' : 'none';
if(!card) return;
if(!tipo || !href){ card.classList.remove('on'); return; }
card.classList.add('on');
card.classList.toggle('neutro', !tipo.drive);
card.href = href;
$('t-link-ic').textContent = tipo.ic;
$('t-link-nome').textContent = tipo.nome;
$('t-link-sub').textContent = 'abrir em nova aba · '+linkDominio(v);
}
function cliqueLinkTarefa(ev){
const h = linkHref($('t-link').value);
if(!h){ ev.preventDefault(); return false; }
ev.currentTarget.href = h;
aposAbrir(h);
return true;
}
function cliqueDrive(ev){
const h = linkHref($('cl-drive').value);
if(!h){ ev.preventDefault(); toast('Cole antes o link da pasta.'); return false; }
ev.currentTarget.href = h; /* a âncora leva o endereço no momento do clique */
aposAbrir(h);
return true;
}
/* =========================================================
BUSCA DA PASTA NO GOOGLE DRIVE
Em vez de colar um endereço, a pessoa procura a pasta na
própria janela do Google e escolhe. A plataforma guarda o
número de identificação da pasta — que não muda quando ela
é renomeada ou movida — além do endereço de sempre.
A permissão pedida ao Google é a mais estreita que existe
(drive.file): alcança apenas o que a pessoa escolher nessa
janela, nunca o Drive inteiro. Nada é lido de dentro dos
arquivos; a plataforma recebe só o nome e o endereço.
========================================================= */
/* Quando o servidor bloqueia um endereço, o navegador avisa por este
evento. Guardamos os últimos avisos para conseguir dizer à pessoa
exatamente o que faltou, em vez de um "não funcionou" genérico. */
let DRIVE_CSP = [];
document.addEventListener('securitypolicyviolation', e=>{
DRIVE_CSP.push({uri:String(e.blockedURI||''), dir:String(e.violatedDirective||''), em:Date.now()});
if(DRIVE_CSP.length>20) DRIVE_CSP.shift();
});
function driveCspRecente(){
const lim = Date.now()-90000;
return DRIVE_CSP.filter(v=>v.em>lim && /google|gstatic/i.test(v.uri));
}
/* A janela do Google chegou a desenhar? Se o quadro não existe ou veio
sem tamanho, o conteúdo foi barrado antes de aparecer. */
function drivePickerNaTela(){
/* O Google cria mais de um quadro com esta classe, e um deles nasce com
tamanho zero. Olhar só o primeiro fazia a plataforma concluir que a
janela não tinha aberto — e fechá-la no meio da digitação. */
const todos = document.querySelectorAll('.picker-dialog');
for(const d of todos){
const r = d.getBoundingClientRect();
if(r.width > 80 && r.height > 80) return true;
}
return false;
}
/* A janela do Google pode desenhar e, dentro dela, mostrar um erro dele
mesmo — que não diz o que fazer. Lemos esse texto para transformá-lo
em instrução. */
const DRIVE_ERROS = [
{re:/chave de desenvolvedor|developer key/i,
msg:'O Google recusou a chave de API. Confira três coisas no Google Cloud, nesta ordem: '+
'(1) em APIs e serviços › Biblioteca, a Google Picker API precisa estar ativada; '+
'(2) nas restrições da chave, a API permitida precisa ser a Google Picker API — não basta a Drive API; '+
'(3) nas restrições por site, precisam constar o endereço desta plataforma e '+
'https://docs.google.com/*. Depois de mexer na chave, o Google leva alguns minutos para valer.'},
{re:/origin|origem/i,
msg:'O Google recusou o endereço desta plataforma. Nas Origens JavaScript autorizadas do cliente OAuth, '+
'inclua exatamente o endereço do site, sem barra no final.'},
{re:/not enabled|não (está )?ativad|disabled/i,
msg:'A API que a janela usa não está ativada no projeto. Em APIs e serviços › Biblioteca, ative a '+
'Google Picker API e a Google Drive API.'}
];
/* Só contam como erro as telas curtas em que o Google avisa que falhou.
Antes esta varredura alcançava o conteúdo da janela: bastava uma pasta
com "erro" no nome, ou um aviso passageiro durante a digitação, para a
plataforma fechar a busca achando que tinha dado errado. */
const DRIVE_ERRO_FORTE = /(ocorreu um erro|there was an error|chave de desenvolvedor|developer key|invalid origin|origem inválida)/i;
function drivePickerErro(){
for(const n of document.querySelectorAll('.picker-dialog')){
const t = (n.innerText||'').replace(/\s+/g,' ').trim();
if(!t || t.length > 220) continue; /* tela de erro é curta; a de busca, não */
if(!DRIVE_ERRO_FORTE.test(t)) continue;
for(const e of DRIVE_ERROS){ if(e.re.test(t)) return e.msg; }
return 'O Google recusou a abertura da janela com a mensagem: “'+esc(t.slice(0,140))+'”.';
}
return '';
}
function driveDiagnostico(){
const erroDoGoogle = drivePickerErro();
if(erroDoGoogle) return erroDoGoogle;
if(drivePickerNaTela()) return null;
const v = driveCspRecente();
if(v.length){
return 'O servidor ainda está com o arquivo .htaccess antigo — foi ele que barrou "'+v[0].uri+
'" (regra '+v[0].dir+'). Suba o .zip completo na Hostinger: o index.html sozinho não resolve, '+
'porque o .htaccess é um arquivo separado.';
}
return 'A janela do Google abriu vazia. Na Chave de API, em restrições por site, é preciso incluir '+
'https://docs.google.com/* além do endereço da plataforma — a janela é servida de lá dentro.';
}
const DRIVE_ESCOPO = 'https://www.googleapis.com/auth/drive.file';
const DRIVE_GSI = 'https://accounts.google.com/gsi/client';
const DRIVE_API = 'https://apis.google.com/js/api.js';
let DRIVE_TOKEN = '', DRIVE_TOKEN_ATE = 0, DRIVE_TC = null, DRIVE_PICKER_OK = false;
function driveCfg(){
S.cfg.drive = Object.assign({clientId:'', apiKey:''}, S.cfg.drive||{});
return S.cfg.drive;
}
function driveAtivo(){ const d=driveCfg(); return !!(d.clientId && d.apiKey); }
/* Carrega um script do Google só quando alguém realmente usa a busca. */
function driveScript(src){
return new Promise((ok, falha)=>{
if([...document.scripts].some(x=>x.src===src)) return ok();
const el=document.createElement('script');
el.src=src; el.async=true; el.defer=true;
el.onload=()=>ok();
el.onerror=()=>falha(new Error('script'));
document.head.appendChild(el);
});
}
async function drivePreparar(){
await driveScript(DRIVE_GSI);
await driveScript(DRIVE_API);
if(!DRIVE_PICKER_OK){
await new Promise((ok,falha)=>{
try{ gapi.load('picker', {callback:()=>{ DRIVE_PICKER_OK=true; ok(); }, onerror:()=>falha(new Error('picker'))}); }
catch(e){ falha(e); }
});
}
}
/* A autorização vale cerca de uma hora e fica só na memória desta aba:
nada de token gravado em disco. Fechou o navegador, pede de novo. */
function driveAutorizar(forcar){
return new Promise((ok, falha)=>{
if(!forcar && DRIVE_TOKEN && Date.now() < DRIVE_TOKEN_ATE) return ok(DRIVE_TOKEN);
try{
if(!DRIVE_TC){
DRIVE_TC = google.accounts.oauth2.initTokenClient({
client_id: driveCfg().clientId,
scope: DRIVE_ESCOPO,
callback: r=>{
if(r && r.access_token){
DRIVE_TOKEN = r.access_token;
DRIVE_TOKEN_ATE = Date.now() + ((+r.expires_in||3600)-120)*1000;
ok(DRIVE_TOKEN);
} else falha(new Error('sem token'));
},
error_callback: e=>falha(e||new Error('autorização cancelada'))
});
} else DRIVE_TC.callback = r=>{
if(r && r.access_token){
DRIVE_TOKEN = r.access_token;
DRIVE_TOKEN_ATE = Date.now() + ((+r.expires_in||3600)-120)*1000;
ok(DRIVE_TOKEN);
} else falha(new Error('sem token'));
};
DRIVE_TC.requestAccessToken({prompt: DRIVE_TOKEN ? '' : 'consent'});
}catch(e){ falha(e); }
});
}
/* Abre a janela do Google já filtrada em pastas. Devolve {id, nome, url}
ou null quando a pessoa fecha sem escolher. */
/* Monta uma aba da janela do Google. Cada ajuste é aplicado só se o
método existir: assim, uma mudança na API do Google tira um detalhe
da janela em vez de derrubar o botão inteiro. */
function driveVista(minhas){
const v = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
const usar = (nome, ...args)=>{ try{ if(typeof v[nome]==='function') v[nome].apply(v, args); }catch(e){} };
usar('setIncludeFolders', true);
usar('setSelectFolderEnabled', true);
usar('setMimeTypes', 'application/vnd.google-apps.folder');
usar('setOwnedByMe', !!minhas);
usar('setLabel', minhas ? 'Meu Drive' : 'Compartilhadas comigo');
if(!minhas) usar('setEnableDrives', true);
return v;
}
function drivePicker(token, aoAbrir){
return new Promise((ok, falha)=>{
let fechou = false;
const b = new google.picker.PickerBuilder();
const usar = (nome, ...args)=>{ try{ if(typeof b[nome]==='function') b[nome].apply(b, args); }catch(e){} };
usar('setOAuthToken', token);
usar('setDeveloperKey', driveCfg().apiKey);
usar('setLocale', 'pt-BR');
usar('setTitle', 'Escolha a pasta do cliente');
/* setOrigin só faz sentido quando a página está dentro de outro quadro.
Fora disso, informá-lo à toa já foi motivo de o Google recusar a chave. */
if(window.self !== window.top) usar('setOrigin', window.location.origin);
try{ b.addView(driveVista(true)); }catch(e){}
try{ b.addView(driveVista(false)); }catch(e){}
const p = b.setCallback(d=>{
const acao = d[google.picker.Response.ACTION];
if(acao===google.picker.Action.PICKED){
const x = (d[google.picker.Response.DOCUMENTS]||[])[0];
fechou = true;
if(x) return ok({id:x.id, nome:x.name||'', url:x.url||('https://drive.google.com/drive/folders/'+x.id)});
return ok(null);
}
if(acao===google.picker.Action.CANCEL){ fechou = true; ok(null); }
}).build();
DRIVE_CSP = []; /* mede só o que esta abertura provocar */
p.setVisible(true);
if(aoAbrir) aoAbrir(); /* o botão volta ao normal: a janela já é a vez da pessoa */
/* Vigia da abertura. Regra de ouro: só fecha a janela diante de erro
explícito do Google ou de bloqueio do servidor. Em qualquer dúvida,
a janela fica aberta — interromper alguém no meio da busca é pior
do que deixar uma tela parada, que a pessoa fecha sozinha. */
setTimeout(()=>{
if(fechou) return;
const erro = drivePickerErro();
const csp = driveCspRecente();
if(!erro && (drivePickerNaTela() || !csp.length)) return; /* na dúvida, não mexe */
const diag = erro || driveDiagnostico();
if(!diag) return;
try{ p.setVisible(false); }catch(e){}
try{ document.querySelectorAll('.picker-dialog-bg,.picker-dialog').forEach(n=>n.remove()); }catch(e){}
fechou = true;
falha(new Error('DIAG:'+diag));
}, 6000);
});
}
/* Traduz as falhas do Google para algo que se resolva sem suporte. */
function driveErro(e){
const m = String((e && (e.message||e.type||e.error)) || e || '');
if(m.indexOf('DIAG:')===0) return m.slice(5);
if(/popup|closed|cancel/i.test(m)) return 'Autorização cancelada — a busca precisa da sua conta Google.';
if(/script|picker/i.test(m)) return 'Não consegui carregar a janela do Google. Verifique a conexão e tente de novo.';
if(/idpiframe|origin/i.test(m)) return 'O endereço deste site não está autorizado no Google Cloud. Confira as "Origens JavaScript autorizadas" do cliente OAuth.';
return 'Não foi possível abrir a busca do Drive. '+(m?('Detalhe: '+m):'');
}
async function driveEscolherPasta(aoAbrir){
await drivePreparar();
const token = await driveAutorizar(false);
return await drivePicker(token, aoAbrir);
}
/* Pergunta ao próprio Google se a chave é aceita vindo deste site.
Devolve '' quando está tudo bem, ou o motivo em português.
É o que separa "chave errada" de "site não autorizado" sem adivinhação. */
async function driveConferirChave(){
const k = driveCfg().apiKey;
if(!k) return 'Não há chave de API gravada.';
if(!/^AIza[0-9A-Za-z_\-]{30,}$/.test(k))
return 'O que está gravado no campo não tem a forma de uma chave de API do Google — elas começam com '+
'AIza e têm cerca de 39 caracteres. Confira se não foi colado ali o ID do cliente, '+
'a chave secreta do OAuth ou um pedaço só da chave.';
let r;
try{
r = await fetch('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest?key='+encodeURIComponent(k));
}catch(e){ return ''; } /* sem rede: não acusa nada */
if(r.ok) return '';
let det = '';
try{ const j = await r.json(); det = (j && j.error && j.error.message) || ''; }catch(e){}
if(/referer|referrer|blocked/i.test(det))
return 'O Google recusou a chave vinda deste site. Mensagem dele: “'+esc(det)+'”. '+
'Nas restrições por site da chave, precisam constar '+esc(window.location.origin)+'/* '+
'e https://docs.google.com/*. Se já constam, apague as duas restrições por alguns minutos '+
'para confirmar que é isso.';
if(/not valid|invalid|expired|deleted/i.test(det))
return 'O Google não reconhece esta chave. Mensagem dele: “'+esc(det)+'”. '+
'Gere uma chave nova em APIs e serviços › Credenciais e cole aqui.';
return 'O Google recusou a chave (código '+r.status+')'+(det?': “'+esc(det)+'”':'')+'.';
}
/* --- usada pelo botão dentro do cadastro do cliente --- */
async function driveBuscarPasta(){
if(!driveAtivo()){ toast('A busca no Drive ainda não foi configurada em Configuração › Escritório.'); return; }
const b = $('cl-drive-buscar'); const antes = b ? b.textContent : '';
const restaurar = ()=>{ if(b){ b.disabled=false; b.textContent = antes||'🔎 Buscar no Drive'; } };
if(b){ b.disabled=true; b.textContent='Abrindo…'; }
try{
const pasta = await driveEscolherPasta(restaurar);
if(pasta){
if($('cl-semdrive') && $('cl-semdrive').checked){ $('cl-semdrive').checked=false; toggleSemDrive(); }
UI.driveEscolha = pasta;
$('cl-drive').value = pasta.url; /* o link é preenchido sozinho, e segue editável */
driveLinkAberto(false);
checaDrive();
toast('Pasta vinculada: '+pasta.nome);
}
}catch(e){ driveAvisar(driveErro(e)); }
finally{ restaurar(); }
}
/* Mensagens de configuração são longas demais para um toast: vão para a
faixa de erro do próprio cadastro, onde a pessoa consegue ler com calma. */
function driveAvisar(msg){
const b = $('cl-err');
if(!b){ toast(edTexto(msg)); return; }
b.innerHTML = msg;
b.classList.add('on');
const ov = $('ov-cliente'); if(ov) ov.scrollTop = 0;
}
/* O vínculo só vale enquanto o endereço no campo for o da pasta escolhida.
Se alguém colar outro link por cima, o número de identificação é
descartado — melhor não ter vínculo do que ter um vínculo errado. */
function driveEscolhaValida(){
const e = UI.driveEscolha;
if(!e || !e.id) return null;
const v = ($('cl-drive') ? ($('cl-drive').value||'') : '').trim();
return (v && v === e.url) ? e : null;
}
function driveBotaoNoForm(){
const b=$('cl-drive-buscar'); if(!b) return;
const sem = $('cl-semdrive') && $('cl-semdrive').checked;
b.style.display = (driveAtivo() && !sem) ? '' : 'none';
}
function toggleSemSenha(){
const sem=$('cl-semsenha').checked;
$('cl-senha').disabled=sem; if(sem) $('cl-senha').value='';
$('cl-senha-req').style.display = sem?'none':'';
$('cl-semsenha-w').classList.toggle('on',sem);
document.querySelector('[data-for="cl-senha"]').classList.remove('err');
}
function toggleSemDrive(){
const sem=$('cl-semdrive').checked;
$('cl-drive').disabled=sem; if(sem) $('cl-drive').value='';
$('cl-drive-req').style.display = sem?'none':'';
$('cl-semdrive-w').classList.toggle('on',sem);
document.querySelector('[data-for="cl-drive"]').classList.remove('err');
checaDrive();
}
function toggleSemProc(){
const sem=$('cl-semproc').checked;
['cl-proc','cl-tribunal'].forEach(id=>{ $(id).disabled=sem; if(sem) $(id).value=''; });
$('cl-proc-req').style.display = sem?'none':'';
$('cl-trib-req').style.display = sem?'none':'';
$('cl-semproc-w').classList.toggle('on',sem);
['cl-proc','cl-tribunal'].forEach(id=>{ const f=document.querySelector('[data-for="'+id+'"]'); if(f) f.classList.remove('err'); });
/* Não zerar o status: um caso sem processo judicial também tem desfecho —
requerimento arquivado no INSS, acordo extrajudicial encerrado, desistência
antes de ajuizar. Zerar aqui apagava, na frente de quem digitava, o que a
pessoa tinha acabado de escolher. */
dicaStatus();
}
/* A dica do campo de status muda conforme haja ou não processo judicial. */
function dicaStatus(){
const el = $('cl-status-hint'); if(!el) return;
const sem = $('cl-semproc') && $('cl-semproc').checked;
/* A explicação longa vive ao lado, no bloco do status. Aqui fica só o
essencial: de onde veio este valor. */
el.textContent = sem
? 'Caso sem processo judicial — este status é definido por você.'
: (UI.cliStatusManual ? 'Definido manualmente por você.'
: 'Atualizado pelas movimentações; pode ser ajustado à mão.');
}
/* Um caso pode ter mais de um tipo de ação (idade rural + pensão por morte,
por exemplo), então o campo é de múltipla escolha com busca por digitação. */
/* Cadastros antigos guardavam um único tipo em "tipoAcao". */
function cliAcoesDe(c){
if(!c) return [];
if(Array.isArray(c.tiposAcao) && c.tiposAcao.length) return c.tiposAcao.slice();
const t = String(c.tipoAcao||'').trim();
return t ? t.split(' · ').map(x=>x.trim()).filter(Boolean) : [];
}
function acoesDaArea(area){
return (CLI_ACOES[area] || CLI_ACOES['Outra'] || []).slice();
}
function areaMudou(){
const a = $('cl-area').value;
const escolhidas = cliAcoesEscolhidas();
const lista = acoesDaArea(a);
/* o que já estava marcado continua marcado, mesmo que a lista da área mude —
ninguém perde uma classificação antiga sem querer */
const guardadas = (UI.cliAcoesAtuais || []).concat(escolhidas);
guardadas.forEach(x=>{ if(x && lista.indexOf(x)<0) lista.push(x); });
const itens = lista.map((nome,i)=>({id:i+1, nome:nome,
sub: acoesDaArea(a).indexOf(nome)>=0 ? '' : 'fora da lista desta área'}));
const sel = itens.filter(x=>escolhidas.indexOf(x.nome)>=0).map(x=>x.id);
msInit('cl-acao', itens, sel);
}
function cliAcoesEscolhidas(){
const m = MS['cl-acao'];
if(!m) return (UI.cliAcoesAtuais || []).slice();
return m.sel.map(v=>{ const it=m.itens.find(x=>x.id===v); return it?it.nome:''; }).filter(Boolean);
}
function menorMudou(){
const on = $('cl-menor').checked;
$('cl-rep').style.display = on ? '' : 'none';
if(!on){ ['cl-rep-nome','cl-rep-cpf','cl-rep-rel','cl-rep-outra'].forEach(id=>{
const f=document.querySelector('[data-for="'+id+'"]'); if(f) f.classList.remove('err'); }); }
relacaoMudou();
}
function relacaoMudou(){
const outra = $('cl-menor').checked && $('cl-rep-rel').value==='Outro';
$('cl-rep-outra-w').style.display = outra ? '' : 'none';
}
function inativoMudou(){
const on = $('cl-inativo').checked;
$('cl-saida').style.display = on ? '' : 'none';
if(!on){ const f=document.querySelector('[data-for="cl-motivo"]'); if(f) f.classList.remove('err'); }
}
function statusManual(){ UI.cliStatusManual = true; dicaStatus(); }
/* O status só é recalculado sozinho quando há processo judicial e ninguém o
definiu à mão. Sem processo não há movimentação que o justifique — e o
recálculo apagaria o desfecho do caso administrativo. */
function statusPodeAuto(){
if(UI.cliStatusManual) return false;
return !($('cl-semproc') && $('cl-semproc').checked);
}
/* ---------- linha do tempo do processo ---------- */
function abrirColar(){ const d=$('cl-colar'); d.style.display = d.style.display==='none'?'':'none';
if(d.style.display==='') $('cl-colar-txt').focus(); }
function abrirLinkTribunal(){
const url=($('cl-tribunal').value||'').trim();
if(!url){ toast('Informe o link do tribunal para abrir o processo.'); $('cl-tribunal').focus(); return; }
abrirLink(url);
$('cl-colar').style.display='';
setTimeout(()=>$('cl-colar-txt').focus(), 300);
toast('Processo aberto no tribunal. Copie as movimentações e cole no campo abaixo.');
}
function caixaConsulta(cls, html){
const el=$('cl-consulta'); if(!el) return;
el.className='consulta'+(cls?' '+cls:'');
el.innerHTML=html; el.style.display='';
}
/* Busca as movimentações na base nacional do CNJ (DataJud), pelo servidor —
o navegador não tem permissão para falar direto com o CNJ. */
async function consultarProcesso(){
const numero = ($('cl-proc').value||'').replace(/\D+/g,'');
const link = ($('cl-tribunal').value||'').trim();
if(numero.length!==20){
caixaConsulta('erro','Número incompleto
A consulta automática usa o número único do CNJ, com 20 dígitos. '+
'Preencha o campo Nº do processo — ou use Abrir no tribunal e lance as movimentações à mão.
Buscando as movimentações na base nacional do Poder Judiciário.
');
let j=null;
try{ j = await srvChamar('processo', {numero:numero, link:link}); }catch(e){ j=null; }
if(bt){ bt.disabled=false; bt.textContent='🔎 Consultar processo'; }
if(!j || !j.ok){
caixaConsulta('erro','Não deu para consultar
'+esc((j&&j.msg)||'Não consegui falar com o servidor.')+
'
Você ainda pode abrir o processo no tribunal e colar as movimentações.
');
return;
}
if(!j.achou){
caixaConsulta('erro','Processo não encontrado no CNJ
'+esc(j.msg||'')+'
');
return;
}
/* funde com o que já está na linha do tempo, sem repetir */
UI.movTmp = UI.movTmp || [];
const chave = x => x.data+'|'+String(x.texto||'').toLowerCase().slice(0,60);
const jaTem = new Set(UI.movTmp.map(chave));
let add=0;
(j.movimentos||[]).forEach(m=>{
const x={data:m.data, texto:m.texto, em:agoraISO(), fonte:'CNJ'};
if(m.hora) x.hora = m.hora;
if(m.grau) x.grau = m.grau;
if(!jaTem.has(chave(x))){ UI.movTmp.push(x); jaTem.add(chave(x)); add++; }
});
UI.movTmp.sort((a,b)=> (b.data>a.data?1:b.dataClasse: '+esc(j.classe));
if(j.orgao) partes.push('Órgão: '+esc(j.orgao));
if((j.graus||[]).length) partes.push('Graus consultados: '+esc(j.graus.join(' · ')));
else if(j.grau) partes.push('Grau: '+esc(j.grau));
if(j.ajuizamento) partes.push('Ajuizado em: '+esc(fmtD(String(j.ajuizamento).slice(0,10))));
if((j.assuntos||[]).length) partes.push('Assunto: '+esc(j.assuntos.slice(0,2).join(' · ')));
/* A base do CNJ é alimentada por cargas dos tribunais: mostrar até quando ela
está em dia evita a impressão de que a consulta "perdeu" movimentações. */
const cargaISO = String(j.atualizado||'').slice(0,10);
let defasagem = '';
if(cargaISO){
const dias = Math.max(0, Math.round((hoje0() - new Date(cargaISO+'T12:00:00')) / 86400000));
defasagem = '
Base do CNJ atualizada em '+esc(fmtD(cargaISO))+''+
(dias>0 ? ' — há '+dias+' dia(s). Movimentações posteriores a essa data ainda não foram enviadas '+
'pelo tribunal ao CNJ; para vê-las agora, use Abrir no tribunal.'
: ' — em dia com o tribunal.')+'
';
}
const ultima = (j.movimentos||[])[0];
caixaConsulta(cargaISO && (hoje0() - new Date(cargaISO+'T12:00:00'))/86400000 > 20 ? 'buscando' : '',
''+esc(j.tribunal)+' · '+(j.movimentos||[]).length+' movimentação(ões) na base do CNJ'+
((j.registros||1)>1 ? ' ('+j.registros+' registros reunidos)' : '')+''+
'
'+(add? ''+add+' nova(s) movimentação(ões) entraram na linha do tempo.' :
'Nada de novo — a linha do tempo já estava em dia.')+
(ultima? ' Última movimentação: '+esc(fmtD(ultima.data))+' — '+esc(String(ultima.texto||'').slice(0,80))+'.' : '')+'
'+
(partes.length?'
'+partes.join('')+'
':'')+
defasagem+
'
Fonte: base nacional do CNJ (DataJud), alimentada por cargas periódicas dos tribunais.
');
toast(add? add+' movimentação(ões) trazida(s) do CNJ.' : 'Consulta feita: nenhuma movimentação nova.');
}
function processarColagem(){
const txt=$('cl-colar-txt').value||'';
const linhas = txt.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);
const novos=[];
let atual=null;
linhas.forEach(l=>{
const m = /^(\d{2})[\/\.-](\d{2})[\/\.-](\d{4})\s*(?:[-–—:]\s*)?(.*)$/.exec(l);
if(m){
if(atual && atual.texto) novos.push(atual);
atual = {data:m[3]+'-'+m[2]+'-'+m[1], texto:(m[4]||'').trim(), em:agoraISO()};
} else if(atual){ atual.texto = (atual.texto?atual.texto+' ':'')+l; }
});
if(atual && atual.texto) novos.push(atual);
if(!novos.length){ toast('Não encontrei linhas começando com data (dd/mm/aaaa).'); return; }
UI.movTmp = UI.movTmp || [];
const chave = x => x.data+'|'+x.texto.toLowerCase().slice(0,60);
const jaTem = new Set(UI.movTmp.map(chave));
let add=0;
novos.forEach(x=>{ if(!jaTem.has(chave(x))){ UI.movTmp.push(x); add++; } });
UI.movTmp.sort((a,b)=> (b.data>a.data?1:b.datam.texto.toLowerCase()).join(' | ');
if(/desist[êe]ncia|renúncia ao direito|homologa[çc][ãa]o da desist/.test(txt)) return 'Desistência';
if(/arquiv|baixa definitiva|trânsito em julgado|extin[çc][ãa]o do processo/.test(txt)) return 'Arquivado';
return 'Em andamento';
}
function renderLinha(){
const movs = UI.movTmp || [];
$('cl-mov-n').textContent = movs.length ? movs.length+' movimentação(ões) registrada(s)' : 'Nenhuma movimentação registrada ainda.';
$('cl-linha').innerHTML = movs.length
? '
'+movs.map((m,i)=>
'
'+fmtD(m.data)+'
'+esc(m.texto)+'
'+
'
').join('')+'
'+
''
: '';
}
function removerMov(i){ UI.movTmp.splice(i,1); if(statusPodeAuto()) $('cl-status').value=statusPorMovimentos(UI.movTmp); renderLinha(); }
async function limparMovs(){ if(!await confirmar('Apagar toda a linha do tempo deste processo?')) return;
UI.movTmp=[]; if(statusPodeAuto()) $('cl-status').value='Não iniciado'; renderLinha(); }
/* ---------- abrir / salvar ---------- */
function editarCliente(id, pre){
if(!pode('gerir_clientes')){ toast('Sem permissão para cadastrar clientes.'); return; }
if($('cl-consulta')){ $('cl-consulta').style.display='none'; $('cl-consulta').innerHTML=''; }
sincronizarAssociados(true);
const c = id? cliente(id) : null;
UI.clienteEdit = id;
UI.movTmp = c && c.movimentacoes ? JSON.parse(JSON.stringify(c.movimentacoes)) : [];
UI.cliStatusManual = !!(c && c.statusManual);
$('cl-kind').textContent = c ? 'Cliente · '+(c.area||'') : 'Cadastro';
$('cl-titulo').textContent = c? c.nome : 'Novo cliente';
$('cl-area').innerHTML = ''+CLI_AREAS.map(a=>'').join('');
$('cl-origem').innerHTML = ''+CLI_ORIGENS.map(a=>'').join('');
$('cl-estado').innerHTML = ''+ESTADOS.map(u=>'').join('');
$('cl-status').innerHTML = PROC_STATUS.map(x=>'').join('');
$('cl-nome').value = c? c.nome : '';
$('cl-doc').value = c? (c.cpfCnpj||c.cpf||'') : ''; mascDoc($('cl-doc'));
$('cl-senha').value = c? (c.senhaInss||'') : '';
$('cl-semsenha').checked = !!(c && c.semSenhaInss); toggleSemSenha();
$('cl-tel').value = c? (c.tel||'') : '';
$('cl-nasc').value = c? (c.nascimento||'') : '';
$('cl-email').value = c? (c.email||'') : '';
$('cl-menor').checked = !!(c && c.menorIdade);
marcarOpcao($('cl-menor'));
$('cl-rep-nome').value = c? (c.repNome||'') : '';
$('cl-rep-cpf').value = c? (c.repCpf||'') : '';
const relGravada = c? (c.repRelacao||'') : '';
const relPadrao = CLI_RELACOES.indexOf(relGravada)>=0 ? relGravada : (relGravada?'Outro':'');
$('cl-rep-rel').innerHTML = ''+
CLI_RELACOES.map(x=>'').join('');
$('cl-rep-outra').value = (relPadrao==='Outro') ? relGravada : '';
menorMudou();
$('cl-inativo').checked = !!(c && c.inativo);
marcarOpcao($('cl-inativo'));
$('cl-motivo').innerHTML = ''+
CLI_MOTIVOS_SAIDA.map(x=>'').join('')+
((c && c.motivoSaida && CLI_MOTIVOS_SAIDA.indexOf(c.motivoSaida)<0)
? '' : '');
$('cl-obs-saida').value = c? (c.obsSaida||'') : '';
$('cl-saida-quando').textContent = (c && c.inativo && c.saidaEm)
? 'Marcado como inativo em '+fmtDH(c.saidaEm)+(c.saidaPor?' por '+c.saidaPor:'')+'.' : '';
inativoMudou();
UI.cliAcoesAtuais = c ? cliAcoesDe(c) : [];
UI.cliAcaoAtual = c? (c.tipoAcao||'') : '';
MS['cl-acao'] = null;
$('cl-acao-wrap').innerHTML = msHTML('cl-acao','Digite para buscar o tipo de ação…');
$('cl-area').value = c? (c.area||'') : ''; areaMudou();
$('cl-origem').value = c? (c.origem||'') : '';
$('cl-estado').value = c? (c.estado||'') : '';
$('cl-contrato').value = c? (c.dataContrato||'') : '';
$('cl-drive').value = c? (c.linkDrive||'') : '';
UI.driveEscolha = (c && c.driveId)
? {id:c.driveId, nome:c.driveNome||'', url:c.linkDrive||''} : null;
driveBotaoNoForm();
$('cl-semdrive').checked = !!(c && c.semDrive); toggleSemDrive();
/* o link só fica à mostra quando não há pasta e não há busca para usar */
driveLinkAberto(!(c && c.linkDrive) && !driveAtivo());
$('cl-proc').value = c? (c.numProcesso||c.processo||'') : '';
$('cl-semproc').checked = !!(c && c.semProcesso); toggleSemProc();
$('cl-tribunal').value = c? (c.tribunal||'') : '';
$('cl-status').value = c? (c.statusProcesso||'Não iniciado') : 'Não iniciado';
dicaStatus();
$('cl-obs').value = c? (c.obs||'') : '';
$('cl-criado').value = c? ((c.criadoPor||'—')+' · '+fmtDH(c.criadoEm)) : (eu().nome+' · '+fmtDH(agoraISO()));
$('cl-alt').value = c && c.alteradoEm ? (c.alteradoPor||'—')+' · '+fmtDH(c.alteradoEm) : '—';
$('cl-parc-wrap').innerHTML = msHTML('cl-parc','Digite o nome do parceiro…');
const parcList = finCadastro('PARCEIRO');
cliPctCarregar(c);
msInit('cl-parc', (parcList||[]).map(x=>({id:x.id, nome:x.nome, sub:x.area||''})),
((c&&c.parceiros)||[]).map(x=>x.id).filter(x=>x!=null));
MS['cl-parc'].onChange = ()=>cliPctRender('parc');
cliPctRender('parc');
if(parcList===null) $('cl-parc-inp').placeholder = 'Abra a Gestão Financeira uma vez para carregar';
$('cl-part-wrap').innerHTML = msHTML('cl-part','Digite o nome de quem atua no caso…');
msInit('cl-part', pessoasCampo(), (c&&c.participantes)||[]);
MS['cl-part'].onChange = ()=>cliPctRender('part');
msEuAtivar('cl-part','Inclui você entre os participantes do escritório neste caso.');
cliPctRender('part');
$('cl-del').style.display = (c && pode('excluir_protegidos'))?'':'none';
$('cl-hist-wrap').style.display = c?'':'none';
renderHistCliente(c);
renderComentariosCliente(c);
montarCaixaComentarioCliente();
renderLinha();
$('cl-colar').style.display='none';
$('cl-err').classList.remove('on');
document.querySelectorAll('#ov-cliente .f').forEach(f=>f.classList.remove('err'));
if(!id && pre) preencherCliente(pre);
abrir('ov-cliente');
}
/* Repõe na janela o que estava guardado num rascunho de cadastro de cliente.
Só é chamada logo depois de editarCliente(null), com a janela já zerada. */
function preencherCliente(d){
if(!d) return;
$('cl-nome').value = d.nome||'';
$('cl-doc').value = d.cpfCnpj||''; mascDoc($('cl-doc'));
$('cl-senha').value = d.senhaInss||'';
$('cl-semsenha').checked = !!d.semSenhaInss; toggleSemSenha();
$('cl-tel').value = d.tel||'';
$('cl-nasc').value = d.nascimento||'';
$('cl-email').value = (d.email||'');
$('cl-menor').checked = !!d.menorIdade; marcarOpcao($('cl-menor'));
$('cl-rep-nome').value = d.repNome||'';
$('cl-rep-cpf').value = d.repCpf||'';
const rel = d.repRelacao||'';
const relPadrao = CLI_RELACOES.indexOf(rel)>=0 ? rel : (rel?'Outro':'');
$('cl-rep-rel').value = relPadrao;
$('cl-rep-outra').value = (relPadrao==='Outro') ? rel : '';
menorMudou();
UI.cliAcoesAtuais = (d.tiposAcao||[]).slice();
$('cl-area').value = d.area||''; areaMudou();
if(MS['cl-acao']){
const m = MS['cl-acao'];
m.sel = m.itens.filter(x=>(d.tiposAcao||[]).indexOf(x.nome)>=0).map(x=>x.id);
msChips('cl-acao');
}
$('cl-origem').value = d.origem||'';
$('cl-estado').value = d.estado||'';
$('cl-contrato').value = d.dataContrato||'';
$('cl-drive').value = d.linkDrive||'';
UI.driveEscolha = d.driveId ? {id:d.driveId, nome:d.driveNome||'', url:d.linkDrive||''} : null;
driveBotaoNoForm();
$('cl-semdrive').checked = !!d.semDrive; toggleSemDrive();
driveLinkAberto(!d.linkDrive && !driveAtivo());
$('cl-proc').value = d.numProcesso||'';
$('cl-tribunal').value = d.tribunal||'';
$('cl-semproc').checked = !!d.semProcesso; toggleSemProc();
UI.cliStatusManual = !!d.statusManual;
$('cl-status').value = d.statusProcesso||'Não iniciado'; dicaStatus();
$('cl-obs').value = d.obs||'';
$('cl-inativo').checked = !!d.inativo; marcarOpcao($('cl-inativo'));
if(d.motivoSaida) $('cl-motivo').value = d.motivoSaida;
$('cl-obs-saida').value = d.obsSaida||'';
inativoMudou();
UI.movTmp = (d.movimentacoes||[]).slice();
renderLinha();
cliPctCarregar({parceiros:d.parceiros, participantesPct:d.participantesPct});
if(MS['cl-parc']){ MS['cl-parc'].sel = ((d.parceiros)||[]).map(x=>x&&x.id).filter(x=>x!=null);
msChips('cl-parc'); cliPctRender('parc'); }
if(MS['cl-part']){ MS['cl-part'].sel = (d.participantes||[]).slice();
msChips('cl-part'); cliPctRender('part'); }
}
/* ---------- rascunho do cadastro de cliente ----------
Espelha o que já acontecia com as tarefas: se a janela fecha antes de
salvar, o que foi digitado fica guardado no canto da tela. */
function rascunhoClienteTemConteudo(o){
if(!o) return false;
return !!(o.nome || String(o.cpfCnpj||'').replace(/\D/g,'') ||
String(o.tel||'').replace(/\D/g,'') || o.email || o.nascimento ||
o.area || (o.tiposAcao||[]).length || o.origem || o.estado ||
o.dataContrato || o.linkDrive || o.numProcesso || o.tribunal ||
o.obs || (o.movimentacoes||[]).length ||
(o.participantes||[]).length || (o.parceiros||[]).length);
}
function capturarRascunhoCliente(){
if(UI.cliSemRascunho){ UI.cliSemRascunho=false; return; }
if(UI.clienteEdit) return; /* só vale para cadastro novo */
if(!$('cl-nome') || !pode('gerir_clientes')) return;
let o; try{ o = coletarCliente(); }catch(e){ return; }
if(!rascunhoClienteTemConteudo(o)) return;
S.rascunhos = (S.rascunhos||[]).filter(r=>!(r.tipo==='cliente' && r.autorId===eu().id));
S.rascunhos.push({tipo:'cliente', dados:o, em:agoraISO(), autorId:eu().id, autor:eu().nome});
salvar(); renderRascunhos();
toast('Rascunho do cadastro guardado. Você pode recuperá-lo no canto da tela.');
}
async function cancelarCliente(){
if(!UI.clienteEdit){
let o=null; try{ o=coletarCliente(); }catch(e){}
if(rascunhoClienteTemConteudo(o) &&
!await confirmar('Descartar o que você preencheu? Se preferir guardar, feche a janela clicando fora dela — aí fica um rascunho.')) return;
}
UI.cliSemRascunho = true; /* impede a captura do rascunho */
fechar('ov-cliente');
}
/* ---------- comentários e atualizações do cliente ---------- */
function montarCaixaComentarioCliente(){
const c = $('cl-cmt-caixa'); if(!c) return;
c.innerHTML = edHTML('cl-cmt-novo', '', 'Escreva um comentário ou registre uma atualização deste cliente…',
{compacto:true, eventos:'oninput="menTeclado(event)" onkeyup="menTeclado(event)" onblur="setTimeout(menFechar,180)"'});
edFoco($('cl-cmt-novo'));
UI.cliCmtEdit = null;
UI.cliArqTmp = [];
cliCmtRenderArquivos();
cliCmtModo();
}
/* ---------- anexos do comentário ----------
O limite existe por um motivo prático: estes arquivos viajam dentro da
base que sincroniza entre os aparelhos. Documento grande é caso de
pasta no Drive, não de anexo. */
const CLI_CMT_MAX_ARQ = 4 * 1024 * 1024; /* 4 MB por arquivo */
const CLI_CMT_MAX_TOTAL = 8 * 1024 * 1024; /* 8 MB somados no mesmo comentário */
function cliCmtTotalArq(){ return (UI.cliArqTmp||[]).reduce((n,a)=>n+(+a.tam||0), 0); }
function cliCmtReceber(ev){
const fs = ev.target.files; if(!fs || !fs.length) return;
[...fs].forEach(f=>{
if(f.size > CLI_CMT_MAX_ARQ){
toast('"'+f.name+'" passa de 4 MB e não foi anexado. Para arquivos grandes, use a pasta do Drive.'); return; }
if(cliCmtTotalArq() + f.size > CLI_CMT_MAX_TOTAL){
toast('Os anexos deste comentário somariam mais de 8 MB. Divida em mais de um comentário ou use a pasta do Drive.'); return; }
const r = new FileReader();
r.onload = ()=>{ (UI.cliArqTmp = UI.cliArqTmp||[]).push(
{nome:f.name, tam:f.size, tipo:f.type, dataUrl:r.result}); cliCmtRenderArquivos(); };
r.readAsDataURL(f);
});
ev.target.value = '';
}
function cliCmtRemoverArq(i){ (UI.cliArqTmp||[]).splice(i,1); cliCmtRenderArquivos(); }
function cliCmtRenderArquivos(){
const el = $('cl-cmt-arqs'); if(!el) return;
const L = UI.cliArqTmp||[];
el.innerHTML = L.map((a,i)=>
'
').join('');
}
/* Os anexos já publicados, na exibição de cada comentário. */
function cliCmtArquivosHTML(x){
const L = (x && x.arquivos) || [];
if(!L.length) return '';
return '
';
}
/* ---------- quem pode mexer em quê ----------
Atualização automática é registro do sistema: ninguém edita, e só a
Administração remove. Comentário escrito à mão pertence a quem o
escreveu — e à Administração, que responde pelo arquivo do escritório. */
function cliCmtMeu(x){
if(!x) return false;
if(x.autorId!=null) return x.autorId === S.usuario;
return x.autor === eu().nome; /* registros antigos, sem o número do autor */
}
function cliCmtPodeExcluir(x){
if(!x) return false;
if(x.sistema) return ehAdmin();
return ehAdmin() || cliCmtMeu(x);
}
function cliCmtPodeEditar(x){
if(!x || x.sistema) return false;
return cliCmtMeu(x);
}
/* Alterna a caixa entre "novo comentário" e "editando aquele ali". */
function cliCmtModo(){
const btn=$('cl-cmt-btn'), can=$('cl-cmt-cancelar'), av=$('cl-cmt-edit-aviso');
if(!btn) return;
const editando = UI.cliCmtEdit != null;
btn.textContent = editando ? 'Salvar alteração' : 'Comentar';
if(can) can.style.display = editando ? '' : 'none';
if(av){ av.style.display = editando ? '' : 'none';
av.textContent = editando ? 'Editando um comentário já publicado.' : ''; }
}
function cmtCliValor(){ return edValor('cl-cmt-novo'); }
function cmtCliTexto(){ return edTexto(edValor('cl-cmt-novo')); }
function cmtCliLimpar(){ const el=$('cl-cmt-novo'); if(el){ el.innerHTML=''; edFoco(el); } }
/* O que veio de um registro financeiro só aparece para quem pode vê-lo. */
function cliComentariosVisiveis(c){
const podeFin = (typeof podeFinanceiro==='function') && podeFinanceiro();
return ((c && c.comentarios) || []).filter(x=>x.so!=='fin' || podeFin);
}
function renderComentariosCliente(c){
const lista = cliComentariosVisiveis(c);
$('cl-cmt-n').textContent = lista.length? '('+lista.length+')' : '';
$('cl-cmt-nota').innerHTML = c
? 'Registre aqui o andamento do cliente. Toda tarefa, prazo, audiência, perícia ou atendimento vinculado a ele entra nesta linha do tempo automaticamente.'
: 'Salve o cadastro para começar a registrar o andamento deste cliente.';
$('cl-cmt-acao').style.display = c ? '' : 'none';
$('cl-cmt-list').innerHTML = lista.length
? lista.slice().reverse().map(x=>{
const i = (c.comentarios||[]).indexOf(x);
const acoes = [];
if(cliCmtPodeEditar(x)) acoes.push('');
if(cliCmtPodeExcluir(x)) acoes.push('');
if(x.tarefaId) acoes.unshift('');
const editando = (UI.cliCmtEdit === i);
return '
';
}
/* Traz o comentário de volta para a caixa de escrita. Só o autor chega aqui. */
function cliCmtEditar(i){
const c = cliente(UI.clienteEdit); if(!c) return;
const x = (c.comentarios||[])[i];
if(!x || !cliCmtPodeEditar(x)){ toast('Só quem escreveu o comentário pode editá-lo.'); return; }
UI.cliCmtEdit = i;
UI.cliArqTmp = (x.arquivos||[]).map(a=>Object.assign({}, a));
const el = $('cl-cmt-novo');
if(el){ el.innerHTML = x.texto || ''; edFoco(el); el.focus(); }
cliCmtRenderArquivos(); cliCmtModo(); renderComentariosCliente(c);
const caixa = $('cl-cmt-caixa'); if(caixa) caixa.scrollIntoView({block:'center'});
}
function cliCmtCancelar(){
const c = cliente(UI.clienteEdit);
UI.cliCmtEdit = null; UI.cliArqTmp = [];
cmtCliLimpar(); cliCmtRenderArquivos(); cliCmtModo();
if(c) renderComentariosCliente(c);
}
async function cliCmtExcluir(i){
const c = cliente(UI.clienteEdit); if(!c) return;
const x = (c.comentarios||[])[i];
if(!x || !cliCmtPodeExcluir(x)){
toast(x && x.sistema ? 'Somente a Administração remove atualizações automáticas.'
: 'Só quem escreveu o comentário pode removê-lo.'); return; }
const rotulo = x.sistema ? 'esta atualização automática' : 'este comentário';
if(!await confirmar('Excluir '+rotulo+'? A remoção fica registrada no histórico de alterações do cliente.')) return;
const resumo = edTexto(x.texto||'').slice(0,90);
c.comentarios.splice(i,1);
c.historico = c.historico||[];
c.historico.push({em:agoraISO(), autor:eu().nome,
txt:(x.sistema?'Atualização automática removida':'Comentário de '+(x.autor||'—')+' removido')+
(resumo?': “'+resumo+'”':'')+'.'});
if(UI.cliCmtEdit === i) cliCmtCancelar();
else if(UI.cliCmtEdit != null && UI.cliCmtEdit > i) UI.cliCmtEdit--;
salvar(); renderComentariosCliente(c); renderHistCliente(c); renderPainel();
toast(x.sistema ? 'Atualização automática removida.' : 'Comentário removido.');
}
function abrirTarefaDoCliente(id){
const t = S.tarefas.find(x=>x.id===id);
if(!t){ toast('Este registro não está mais na base.'); return; }
fechar('ov-cliente'); abrirTarefa(id);
}
async function addComentarioCliente(){
const c = cliente(UI.clienteEdit); if(!c) return;
/* comentário sem texto ainda vale quando traz anexo: às vezes o
documento é o recado inteiro */
if(!cmtCliTexto() && !(UI.cliArqTmp||[]).length){ toast('Escreva o comentário ou anexe um arquivo.'); return; }
/* os arquivos vão para o servidor: na base fica o número deles, não o
conteúdo — e só depois de haver o que gravar */
if((UI.cliArqTmp||[]).some(a=>a && a.dataUrl && !a.ref)){
try{ await anexosSubirLista(UI.cliArqTmp); }catch(e){}
try{ cliCmtRenderArquivos(); }catch(e){}
}
const arqs = (UI.cliArqTmp||[]).slice();
const v = cmtCliValor();
menFechar();
const mencoes = acharMencoes(cmtCliTexto()).filter(id=>id!==S.usuario);
c.comentarios = c.comentarios||[];
/* --- salvando a edição de um comentário já publicado --- */
if(UI.cliCmtEdit != null){
const x = c.comentarios[UI.cliCmtEdit];
if(!x || !cliCmtPodeEditar(x)){ toast('Este comentário não pode mais ser editado.'); cliCmtCancelar(); return; }
const mudouTexto = String(x.texto||'') !== String(v||'');
const mudouArq = JSON.stringify((x.arquivos||[]).map(a=>a.nome)) !== JSON.stringify(arqs.map(a=>a.nome));
x.texto = v; x.arquivos = arqs; x.mencoes = mencoes;
if(mudouTexto || mudouArq){
x.editadoEm = agoraISO(); x.editadoPor = eu().nome;
c.historico = c.historico||[];
c.historico.push({em:agoraISO(), autor:eu().nome, txt:'Comentário editado no cadastro do cliente.'});
}
cliCmtCancelar();
salvar(); renderComentariosCliente(c); renderHistCliente(c); renderSino(); renderPopNotif();
toast('Comentário atualizado.');
return;
}
/* --- comentário novo --- */
c.comentarios.push({autor:eu().nome, autorId:S.usuario, em:agoraISO(), texto:v,
mencoes:mencoes, arquivos:arqs});
UI.cliArqTmp = [];
cmtCliLimpar(); cliCmtRenderArquivos();
salvar(); renderComentariosCliente(c); renderPainel(); renderSino(); renderPopNotif();
if(mencoes.length){
const nomes = mencoes.map(id=>{const m=membro(id);return m?m.nome.split(/\s+/)[0]:'';}).filter(Boolean);
toast('Comentário publicado — '+nomes.join(', ')+(mencoes.length>1?' foram avisados':' foi avisado')+' na plataforma.');
} else toast('Comentário publicado no cadastro do cliente.');
}
/* ---------- andamento automático vindo das tarefas ----------
Toda tarefa (de qualquer tipo) vinculada a um cliente deixa registro no
cartão dele. É o que centraliza a rotina do caso em um lugar só. */
function cliAndamentoTexto(t, acao, extra){
const T = TIPOS[t.tipo] ? TIPOS[t.tipo] : null;
const partes = [];
if(t.acao) partes.push(t.acao);
if(t.data) partes.push((T?T.dataLabel:'Data')+' '+fmtD(t.data)+(t.hora?' às '+faixaHora(t):''));
if(t.status && !/status/i.test(extra||'')) partes.push('Status: '+t.status);
const resp = (t.responsaveis||[]).map(id=>{const m=membro(id);return m?m.nome:'';}).filter(Boolean);
if(resp.length) partes.push((resp.length>1?'Responsáveis: ':'Responsável: ')+resp.join(', '));
if(extra) partes.push(extra);
return ''+esc((T?T.kind:'Registro')+' nº '+t.id+' '+acao)+': '+esc(t.descricao||'sem descrição')+
(partes.length ? ' '+esc(partes.join(' · '))+'' : '');
}
function cliAndamentoEm(ids, t, texto){
(ids||[]).forEach(id=>{
const c = cliente(id); if(!c) return;
c.comentarios = c.comentarios||[];
const ult = c.comentarios[c.comentarios.length-1];
/* dois salvamentos seguidos sem novidade não viram duas linhas iguais */
if(ult && ult.sistema && ult.tarefaId===t.id && ult.texto===texto) return;
c.comentarios.push({autor:eu().nome, autorId:eu().id, em:agoraISO(),
texto:texto, sistema:true, tarefaId:t.id, mencoes:[],
/* registro financeiro segue a mesma regra de sigilo das tarefas */
so: (t.tipo==='financeiro' ? 'fin' : '')});
});
}
function renderHistCliente(c){
const h = (c && c.historico) || [];
$('cl-hist-n').textContent = h.length? '('+h.length+')' : '';
$('cl-hist-nota').innerHTML = ehAdmin()
? 'Tudo o que muda neste cadastro fica registrado. Como Administração, você pode remover linhas.'
: 'Tudo o que muda neste cadastro fica registrado. Não pode ser editado por este perfil.';
$('cl-hist-list').innerHTML = h.length
? '
';
}
async function apagarHistCli(i){
if(!ehAdmin()) return; const c=cliente(UI.clienteEdit); if(!c) return;
if(!await confirmar('Remover esta linha do histórico?')) return;
c.historico.splice(i,1); salvar(); renderHistCliente(c);
}
/* =========================================================
PERCENTUAL DE REPASSE — cadastro do cliente
Ao escolher um parceiro externo ou um participante do escritório, abre-se
um campo por pessoa para registrar quanto daquele contrato será transferido
quando houver retorno financeiro. É esse número que a tarefa financeira
puxa depois, para ninguém precisar lembrar de cabeça.
========================================================= */
const CLI_PCT = {parc:{}, part:{}};
function pctFmt(n){ return (Math.round(n*100)/100).toFixed(2).replace('.',',')+'%'; }
function cliPctSoma(qual){
const m = MS[qual==='parc'?'cl-parc':'cl-part'];
return (((m&&m.sel)||[]).reduce((a,id)=> a + (parseFloat(CLI_PCT[qual][id])||0), 0));
}
function cliPctRodape(qual, soma){
if(soma<=0) return 'Sem percentual informado — o contrato fica integralmente com o escritório.';
if(soma>100) return 'Total repassado: '+pctFmt(soma)+' — passou de 100% do contrato, confira os números.';
return 'Total repassado: '+pctFmt(soma)+' · o escritório fica com '+pctFmt(100-soma)+'.';
}
function cliPctRender(qual){
const box = $('cl-'+qual+'-pct'); if(!box) return;
const m = MS[qual==='parc'?'cl-parc':'cl-part'];
const sel = (m&&m.sel)||[];
if(!sel.length){ box.innerHTML=''; box.style.display='none'; return; }
box.style.display='';
const soma = cliPctSoma(qual);
box.innerHTML =
'
'+(qual==='parc'?'Parceiro externo':'Participante do escritório')+
'% do contrato
'+
sel.map(id=>{ const it=(m.itens||[]).find(x=>x.id===id);
const v = CLI_PCT[qual][id];
return '
'+esc(it?it.nome:'—')+''+
'%
';
}).join('')+
'
'+cliPctRodape(qual,soma)+'
';
}
function cliPctSet(qual,id,v){
const t = String(v==null?'':v).replace(',','.');
CLI_PCT[qual][id] = t==='' ? '' : Math.max(0, Math.min(100, parseFloat(t)||0));
const box=$('cl-'+qual+'-pct'); if(!box) return;
const soma=cliPctSoma(qual), rod=box.querySelector('.pcttot');
if(rod){ rod.classList.toggle('alto', soma>100); rod.innerHTML = cliPctRodape(qual,soma); }
}
function cliPctCarregar(c){
CLI_PCT.parc={}; CLI_PCT.part={};
((c&&c.parceiros)||[]).forEach(x=>{ if(x && x.id!=null && x.pct!=null && x.pct!=='') CLI_PCT.parc[x.id]=x.pct; });
((c&&c.participantesPct)||[]).forEach(x=>{ if(x && x.id!=null && x.pct!=null && x.pct!=='') CLI_PCT.part[x.id]=x.pct; });
}
function cliPctColher(qual, nomeDe){
const m = MS[qual==='parc'?'cl-parc':'cl-part'];
return (((m&&m.sel)||[])).map(id=>{
const it=(m.itens||[]).find(x=>x.id===id);
const v = CLI_PCT[qual][id];
return {id:id, nome: it?it.nome:(nomeDe?nomeDe(id):''), pct: (v==null||v==='') ? null : +v};
});
}
function coletarCliente(){
const o = {
nome:($('cl-nome').value||'').trim(),
cpfCnpj:($('cl-doc').value||'').trim(),
senhaInss: $('cl-semsenha').checked ? '' : ($('cl-senha').value||'').trim(),
semSenhaInss: $('cl-semsenha').checked,
tel:$('cl-tel').value||'', nascimento:($('cl-nasc').value||''),
email:($('cl-email').value||'').trim(),
area:$('cl-area').value, tiposAcao:cliAcoesEscolhidas(), tipoAcao:cliAcoesEscolhidas().join(' · '),
origem:$('cl-origem').value, estado:$('cl-estado').value,
dataContrato:$('cl-contrato').value,
linkDrive: $('cl-semdrive').checked ? '' : ($('cl-drive').value||'').trim(),
semDrive: $('cl-semdrive').checked,
driveId: $('cl-semdrive').checked ? '' : ((driveEscolhaValida()||{}).id || ''),
driveNome: $('cl-semdrive').checked ? '' : ((driveEscolhaValida()||{}).nome || ''),
numProcesso: $('cl-semproc').checked ? '' : ($('cl-proc').value||'').trim(),
semProcesso: $('cl-semproc').checked,
tribunal: $('cl-semproc').checked ? '' : ($('cl-tribunal').value||'').trim(),
statusProcesso: $('cl-status').value, statusManual: !!UI.cliStatusManual,
movimentacoes: (UI.movTmp||[]).slice(),
participantes: msSel('cl-part'),
participantesPct: cliPctColher('part', id=>{ const m=membro(id); return m?m.nome:''; }),
obs:($('cl-obs').value||'').trim(),
menorIdade: $('cl-menor').checked,
repNome: $('cl-menor').checked ? ($('cl-rep-nome').value||'').trim() : '',
repCpf: $('cl-menor').checked ? ($('cl-rep-cpf').value||'').trim() : '',
repRelacao: $('cl-menor').checked
? ($('cl-rep-rel').value==='Outro' ? ($('cl-rep-outra').value||'').trim() : $('cl-rep-rel').value)
: '',
inativo: $('cl-inativo').checked,
motivoSaida: $('cl-inativo').checked ? $('cl-motivo').value : '',
obsSaida: $('cl-inativo').checked ? ($('cl-obs-saida').value||'').trim() : ''
};
const lista = finCadastro('PARCEIRO')||[];
const pctParc = {}; cliPctColher('parc').forEach(x=>{ pctParc[x.id]=x.pct; });
o.parceiros = msSel('cl-parc').map(id=>{ const x=lista.find(y=>y.id===id);
return x ? {id:x.id, nome:x.nome, pct:(pctParc[id]==null?null:pctParc[id])} : null; }).filter(Boolean);
o.tipoDoc = tipoDoc(o.cpfCnpj);
o.areas = o.area ? [o.area] : [];
o.processo = o.numProcesso; /* compatibilidade com os formulários de tarefa */
return o;
}
function validarCliente(o){
const e=[]; const marca=(id,ok)=>{ const f=document.querySelector('#ov-cliente [data-for="'+id+'"]'); if(f) f.classList.toggle('err',!ok); };
const nomeOk = !!o.nome && o.nome === o.nome.toUpperCase();
marca('cl-nome', nomeOk);
if(!o.nome) e.push('Nome completo');
else if(!nomeOk) e.push('Nome completo (precisa estar todo em maiúsculas)');
const nd = o.cpfCnpj.replace(/\D/g,'').length;
marca('cl-doc', nd===11||nd===14); if(nd!==11 && nd!==14) e.push('CPF/CNPJ (11 ou 14 dígitos)');
const senhaOk = o.semSenhaInss || !!o.senhaInss;
marca('cl-senha', senhaOk); if(!senhaOk) e.push('Senha Meu INSS');
const telOk = o.tel.replace(/\D/g,'').length>=10;
marca('cl-tel', telOk); if(!telOk) e.push('Telefone com DDD');
if(o.email && !emailOk(o.email)) e.push('E-mail inválido');
marca('cl-area', !!o.area); if(!o.area) e.push('Área do Direito');
marca('cl-acao', (o.tiposAcao||[]).length>0); if(!(o.tiposAcao||[]).length) e.push('Tipo de ação');
marca('cl-origem', !!o.origem); if(!o.origem) e.push('Origem');
marca('cl-estado', !!o.estado); if(!o.estado) e.push('Estado');
marca('cl-contrato', !!o.dataContrato); if(!o.dataContrato) e.push('Assinatura do contrato');
const driveOk = o.semDrive || !!o.linkDrive;
marca('cl-drive', driveOk); if(!driveOk) e.push('Link da pasta no Drive');
const procOk = o.semProcesso || !!o.numProcesso;
marca('cl-proc', procOk); if(!procOk) e.push('Nº do processo');
const tribOk = o.semProcesso || !!o.tribunal;
marca('cl-tribunal', tribOk); if(!tribOk) e.push('Tribunal');
const motivoOk = !o.inativo || !!o.motivoSaida;
marca('cl-motivo', motivoOk); if(!motivoOk) e.push('Motivo da saída');
if(o.menorIdade){
const repOk = !!o.repNome && o.repNome === o.repNome.toUpperCase();
marca('cl-rep-nome', repOk);
if(!o.repNome) e.push('Nome do representante');
else if(!repOk) e.push('Nome do representante (precisa estar todo em maiúsculas)');
const cpfN = String(o.repCpf||'').replace(/\D/g,'').length===11;
marca('cl-rep-cpf', cpfN); if(!cpfN) e.push('CPF do representante');
marca('cl-rep-rel', !!o.repRelacao);
marca('cl-rep-outra', !!o.repRelacao);
if(!o.repRelacao) e.push('Relação com o menor');
}
return e;
}
function difCliente(c,o){
const reg=[], cmp=(rot,a,b)=>{ if(String(a==null?'':a)!==String(b==null?'':b)) reg.push(rot+': "'+(a||'—')+'" → "'+(b||'—')+'"'); };
cmp('Nome',c.nome,o.nome); cmp('CPF/CNPJ',c.cpfCnpj,o.cpfCnpj); cmp('Telefone',c.tel,o.tel); cmp('E-mail',c.email,o.email);
cmp('Data de nascimento', c.nascimento?fmtD(c.nascimento):'', o.nascimento?fmtD(o.nascimento):'');
cmp('Área',c.area,o.area); cmp('Tipo de ação',c.tipoAcao,o.tipoAcao); cmp('Origem',c.origem,o.origem);
cmp('Estado',c.estado,o.estado); cmp('Contrato',c.dataContrato?fmtD(c.dataContrato):'',o.dataContrato?fmtD(o.dataContrato):'');
cmp('Pasta no Drive', c.semDrive?'não há':(c.driveNome||c.linkDrive||''),
o.semDrive?'não há':(o.driveNome||o.linkDrive||'')); cmp('Nº do processo',c.numProcesso,o.numProcesso);
cmp('Tribunal',c.tribunal,o.tribunal); cmp('Status do processo',c.statusProcesso,o.statusProcesso);
cmp('Senha Meu INSS', c.semSenhaInss?'não é necessária':(c.senhaInss?'informada':''),
o.semSenhaInss?'não é necessária':(o.senhaInss?'informada':''));
cmp('Parceiros', finPartResumo(c.parceiros), finPartResumo(o.parceiros));
cmp('Participantes', finPartResumo(c.participantesPct) ||
(c.participantes||[]).map(id=>(membro(id)||{}).nome).join(', '),
finPartResumo(o.participantesPct) ||
(o.participantes||[]).map(id=>(membro(id)||{}).nome).join(', '));
const na=(c.movimentacoes||[]).length, nb=(o.movimentacoes||[]).length;
if(na!==nb) reg.push('Movimentações do processo: '+na+' → '+nb);
cmp('Observações',c.obs,o.obs);
if(!!c.menorIdade !== !!o.menorIdade){
reg.push(o.menorIdade ? 'Passou a constar com representante: '+(o.repNome||'—')+
(o.repRelacao?' ('+o.repRelacao+')':'')
: 'Deixou de constar com representante');
} else if(o.menorIdade){
cmp('Representante', c.repNome, o.repNome);
cmp('CPF do representante', c.repCpf, o.repCpf);
cmp('Relação com o menor', c.repRelacao, o.repRelacao);
}
if(!!c.inativo !== !!o.inativo){
reg.push(o.inativo ? 'Cliente marcado como INATIVO — motivo: '+(o.motivoSaida||'não informado')
: 'Cliente reativado na carteira');
} else if(o.inativo){
cmp('Motivo da saída', c.motivoSaida, o.motivoSaida);
cmp('Observações da saída', c.obsSaida, o.obsSaida);
}
return reg;
}
function salvarCliente(){
const o=coletarCliente(), err=validarCliente(o);
if(err.length){ const b=$('cl-err'); b.innerHTML='Revise: '+err.map(esc).join(', ')+'.'; b.classList.add('on');
$('ov-cliente').scrollTop=0; return; }
$('cl-err').classList.remove('on');
if(UI.clienteEdit){
const c=cliente(UI.clienteEdit);
c.comentarios = c.comentarios||[];
const reg = difCliente(c,o);
const eraInativo = !!c.inativo;
Object.assign(c,o);
if(o.inativo && !eraInativo){ c.saidaEm=agoraISO(); c.saidaPor=eu().nome; }
if(!o.inativo){ c.saidaEm=null; c.saidaPor=''; }
c.alteradoEm=agoraISO(); c.alteradoPor=eu().nome;
c.historico=c.historico||[];
if(reg.length) c.historico.push({em:agoraISO(),autor:eu().nome,txt:reg.join(' · ')});
} else {
const c = Object.assign({id:novoId('cliente')}, o, {
saidaEm: o.inativo?agoraISO():null, saidaPor: o.inativo?eu().nome:'',
criadoPor:eu().nome, criadoPorId:eu().id, criadoEm:agoraISO(),
alteradoEm:null, alteradoPor:null, comentarios:[],
historico:[{em:agoraISO(),autor:eu().nome,txt:'Cliente cadastrado.'}]});
S.clientes.push(c);
S.rascunhos = (S.rascunhos||[]).filter(r=>!(r.tipo==='cliente' && r.autorId===eu().id));
}
UI.cliSemRascunho = true;
salvar(); renderRascunhos(); fechar('ov-cliente');
if(UI.painel!=='clientes' && !$('ov-cfg').classList.contains('on')) UI.painel='clientes';
redesenhar(); renderCfg && ($('ov-cfg').classList.contains('on') && renderCfg());
toast(UI.clienteEdit?'Cliente atualizado.':'Cliente cadastrado.');
}
async function excluirCliente(){
const id=UI.clienteEdit; if(!id) return;
if(!pode('excluir_protegidos')){ toast('Somente a Administração pode excluir clientes.'); return; }
const usado=S.tarefas.some(t=>(t.clientes||[]).indexOf(id)>=0);
if(usado){ toast('Este cliente está vinculado a registros existentes e não pode ser excluído.'); return; }
if(!await confirmar('Excluir este cliente?')) return;
S.clientes=S.clientes.filter(c=>c.id!==id);
salvar(); fechar('ov-cliente'); redesenhar(); toast('Cliente excluído.');
}
/* ---------- painel de clientes ---------- */
function cliEstado(){
if(!UI.cli) UI.cli = {vista:'kanban', grupo:'mes', f:{mes:'',area:'',acao:'',origem:'',estado:'',status:'',situacao:'',motivo:''},
ord:'nome', kpi:'', ordCli:'nome', ordCol:{}};
if(UI.cli.ordCli===undefined) UI.cli.ordCli='nome'; /* vazio = ordenado por uma coluna da tabela */
if(!UI.cli.ordCol) UI.cli.ordCol={};
return UI.cli;
}
/* As quatro maneiras de ordenar a carteira. Valem para a lista e para cada
listagem do quadro, que pode ter a sua própria. */
const CLI_ORDENS = [
{k:'nome', n:'Nome (A–Z)', curto:'A–Z'},
{k:'nome-z', n:'Nome (Z–A)', curto:'Z–A'},
{k:'contrato', n:'Contrato (mais recente)', curto:'contrato ↓'},
{k:'contrato-ant',n:'Contrato (mais antigo)', curto:'contrato ↑'}
];
function ordenarClientes(lista, ordem){
const nome = (a,b)=>String(a.nome||'').localeCompare(String(b.nome||''),'pt-BR',{sensitivity:'base'});
/* sem data de contrato, o cliente vai para o fim nos dois sentidos */
const data = (a,b,dir)=>{
const da=a.dataContrato||'', db=b.dataContrato||'';
if(!da && !db) return nome(a,b);
if(!da) return 1;
if(!db) return -1;
if(da===db) return nome(a,b);
return dir * (da>db ? 1 : -1);
};
const arr = lista.slice();
if(ordem==='nome-z') arr.sort((a,b)=>nome(b,a));
else if(ordem==='contrato') arr.sort((a,b)=>data(a,b,-1));
else if(ordem==='contrato-ant') arr.sort((a,b)=>data(a,b,1));
else arr.sort(nome);
return arr;
}
function setCliOrdem(k){
const st=cliEstado();
if(!k) return;
st.ordCli = k;
st.ord = ''; /* deixa de valer a coluna clicada na tabela */
st.ordCol = {}; /* e todas as listagens voltam a seguir a barra */
renderClientes();
}
/* ordem de uma listagem do quadro: a própria, se tiver, senão a da barra */
function ordemDaColuna(chave){
const st=cliEstado();
return st.ordCol[chave] || st.ordCli || 'nome';
}
function setCliOrdemCol(chave, k){
const st=cliEstado();
if(k===st.ordCli) delete st.ordCol[chave]; else st.ordCol[chave]=k;
renderClientes();
}
function setCliGrupo(g){ const st=cliEstado(); st.grupo=g; st.f[g]=''; renderClientes(); }
function setCliVista(v){ cliEstado().vista=v;
[...$('cli-seg').children].forEach((b,i)=>b.classList.toggle('on',(i===0)===(v==='kanban')));
renderClientes(); }
const CLI_DIMS = [
{k:'mes', n:'Mês do contrato', val:c=>c.dataContrato?c.dataContrato.slice(0,7):'', rot:v=>v?cap1(MESES_NOME[+v.slice(5,7)-1])+' de '+v.slice(0,4):'Sem contrato'},
{k:'area', n:'Área', val:c=>c.area||'', rot:v=>v||'Sem área'},
{k:'acao', n:'Tipo de ação', val:c=>c.tipoAcao||'', rot:v=>v||'Sem ação'},
{k:'origem', n:'Origem', val:c=>c.origem||'', rot:v=>v||'Sem origem'},
{k:'estado', n:'Estado', val:c=>c.estado||'', rot:v=>{const u=ESTADOS.find(x=>x[0]===v); return u?u[0]+' — '+u[1]:'Sem estado';}},
{k:'status', n:'Status', val:c=>statusCaso(c), rot:v=>v||'Não iniciado'},
{k:'situacao',n:'Situação', val:c=>c.inativo?'Inativo':'Ativo', rot:v=>v||'Ativo'},
{k:'motivo', n:'Motivo da saída', val:c=>c.inativo?(c.motivoSaida||'Sem motivo informado'):'',
rot:v=>v||'Na carteira'}
];
const cliDim = k => CLI_DIMS.find(d=>d.k===k);
function valoresDim(k){
const d=cliDim(k), set=new Set();
S.clientes.forEach(c=>set.add(d.val(c)));
return [...set].filter(v=>v!=='').sort((a,b)=> k==='mes' ? (b>a?1:-1) : String(a).localeCompare(String(b),'pt-BR'));
}
function clientesFiltrados(semKpi){
const st=cliEstado(), q=($('cli-q').value||'').toLowerCase().trim();
const base = S.clientes.filter(c=>{
for(const d of CLI_DIMS){ const fv=st.f[d.k]; if(fv && d.val(c)!==fv) return false; }
if(q){
const txt=[c.nome,c.cpfCnpj,c.tel,c.email,c.numProcesso,c.tipoAcao,c.area,c.origem,c.obs,
(c.parceiros||[]).map(x=>x.nome).join(' ')].join(' ').toLowerCase();
if(txt.indexOf(q)<0) return false;
}
return true;
});
return (semKpi || !st.kpi) ? base : clientesDoKpi(st.kpi, base);
}
function renderClientes(){
const st=cliEstado();
const ordemBotoes=['mes','area','acao','origem','estado','status','motivo'];
[...$('cli-seg-grupo').children].forEach((b,i)=>b.classList.toggle('on', ordemBotoes[i]===st.grupo));
/* barra de cruzamento: todas as dimensões, menos a que já agrupa */
$('cli-cruz').innerHTML = 'Cruzar com' +
CLI_DIMS.map(d=>{
const desativado = d.k===st.grupo;
const vals = valoresDim(d.k);
return '';
}).join('');
const lista = clientesFiltrados();
const ativos = CLI_DIMS.filter(d=>st.f[d.k]);
$('cli-cruz').innerHTML += ''+lista.length+' de '+S.clientes.length+' clientes'+
(ativos.length? ' · '+ativos.map(d=>d.rot(st.f[d.k])).join(' + ') : '')+
(st.kpi? ' · '+CLI_KPI[st.kpi].n : '')+'';
const nf = ativos.length + (($('cli-q').value||'').trim()?1:0) + (st.kpi?1:0);
$('cli-limpar').disabled = nf===0; $('cli-count').textContent = nf?nf:''; $('cli-count').style.display = nf?'':'none';
$('cli-q').classList.toggle('act', !!($('cli-q').value||'').trim());
$('cli-ord').innerHTML = CLI_ORDENS.map(o=>'').join('')+
(st.ordCli?'':'');
renderCliKpis(lista);
if(st.vista==='lista') renderCliLista(lista); else renderCliKanban(lista);
}
function limparFiltrosCli(){ const st=cliEstado(); CLI_DIMS.forEach(d=>st.f[d.k]=''); st.kpi=''; $('cli-q').value=''; renderClientes(); toast('Filtros limpos.'); }
const CLI_KPI = {
todos: {n:'Clientes', d:'todos os clientes do recorte atual'},
and: {n:'Processos em andamento', d:'com processo em curso'},
nao: {n:'Aguardando distribuição', d:'com processo ainda não iniciado'},
novos: {n:'Novos no mês', d:'contrato assinado no mês corrente'},
semproc:{n:'Sem processo judicial', d:'casos apenas administrativos'},
inativos:{n:'Fora da carteira', d:'clientes marcados como inativos'},
ativos: {n:'Clientes ativos', d:'clientes que seguem na carteira'},
semnasc:{n:'Sem data de nascimento', d:'clientes ativos cujo aniversário a plataforma ainda não conhece'}
};
/* Contagem das saídas por motivo. Aparece quando a pessoa está olhando os
inativos — cada faixa filtra a carteira por aquele motivo. */
function renderSaidas(base){
const st=cliEstado(), cx=$('cli-saidas'); if(!cx) return;
const mostrar = st.kpi==='inativos' || !!st.f.motivo || st.grupo==='motivo';
const foras = (base||[]).filter(c=>c.inativo);
if(!mostrar || !foras.length){ cx.style.display='none'; cx.innerHTML=''; return; }
const conta = {};
foras.forEach(c=>{ const m=c.motivoSaida||'Sem motivo informado'; conta[m]=(conta[m]||0)+1; });
const ordem = CLI_MOTIVOS_SAIDA.concat(Object.keys(conta).filter(m=>CLI_MOTIVOS_SAIDA.indexOf(m)<0));
cx.style.display='';
cx.innerHTML = 'Saídas por motivo'+
''+
ordem.filter(m=>conta[m]).map(m=>
'').join('');
}
function filtrarMotivo(m){
const st=cliEstado();
st.f.motivo = (st.f.motivo===m)?'':m;
/* o recorte por motivo só faz sentido entre os inativos; ao soltá-lo,
a carteira inteira volta a aparecer */
st.f.situacao = st.f.motivo ? 'Inativo' : '';
renderClientes();
}
function clientesDoKpi(id, base){
const lista = base || clientesFiltrados(true);
if(id==='and') return lista.filter(c=>c.statusProcesso==='Em andamento' && !c.semProcesso);
if(id==='nao') return lista.filter(c=>(c.statusProcesso||'Não iniciado')==='Não iniciado' && !c.semProcesso);
if(id==='semproc') return lista.filter(c=>c.semProcesso);
if(id==='inativos') return lista.filter(c=>c.inativo);
if(id==='ativos') return lista.filter(c=>!c.inativo);
if(id==='novos') return lista.filter(c=>(c.dataContrato||'').slice(0,7)===iso(hoje0()).slice(0,7));
if(id==='semnasc') return lista.filter(c=>!c.inativo && !c.nascimento);
return lista;
}
function renderCliKpis(lista){
const st=cliEstado();
const box=(id,cls,k,v,n)=>'';
const base = clientesFiltrados(true);
$('cli-kpis').innerHTML =
box('todos','k-aberto','Clientes', base.length, clientesDoKpi('ativos',base).length+' ativo(s) na carteira')+
box('and','k-meus','Processos em andamento', clientesDoKpi('and',base).length, 'com movimentação')+
box('nao','k-sem','Aguardando distribuição', clientesDoKpi('nao',base).length, 'processo não iniciado')+
box('novos','k-conc','Novos no mês', clientesDoKpi('novos',base).length, cap1(MESES_NOME[hoje0().getMonth()]))+
box('semproc','','Sem processo judicial', clientesDoKpi('semproc',base).length, 'apenas administrativo')+
box('inativos','k-fora','Fora da carteira', clientesDoKpi('inativos',base).length, 'clientes inativos');
renderSaidas(base);
renderAniversarios(base);
}
/* ---------------------------------------------------------------
ANIVERSÁRIOS DA CARTEIRA
Uma lembrança no dia certo é das poucas coisas que o cliente nota sem
que se peça nada em troca. A faixa mostra quem faz aniversário hoje e
quem faz nos próximos sete dias, com o telefone à mão para a mensagem.
Clientes fora da carteira não entram.
--------------------------------------------------------------- */
/* Quantos anos o cliente completa no próximo aniversário. Nome próprio para
não colidir com a conta de idade da equipe, que lê outro campo. */
function idadeCliente(c){
const dm = diaMesDe(c && c.nascimento); if(!dm || !dm.ano) return null;
const h = hoje0();
let ano = h.getFullYear();
const alvo = new Date(ano, dm.m-1, dm.d);
if(alvo < h) ano++;
const n = ano - dm.ano;
return (n>0 && n<130) ? n : null;
}
function aniversariantes(base, ate){
return (base||[]).filter(c=>!c.inativo && c.nascimento).map(c=>({c:c, dias:gpDiasAte(c.nascimento)}))
.filter(o=>o.dias!=null && o.dias<=ate)
.sort((a,b)=>a.dias-b.dias || String(a.c.nome).localeCompare(String(b.c.nome),'pt-BR'));
}
function renderAniversarios(base){
const cx=$('cli-aniv'); if(!cx) return;
const lista = aniversariantes(base, 7);
if(!lista.length){ cx.style.display='none'; cx.innerHTML=''; return; }
cx.style.display='';
const cartao = o => {
const c=o.c, dm=diaMesDe(c.nascimento), idade=idadeCliente(c);
const quando = o.dias===0 ? 'hoje' : o.dias===1 ? 'amanhã' : 'em '+o.dias+' dias';
const zap = (c.tel||'').replace(/\D/g,'');
return '
'+
(c.inativo?'✕ fora da carteira':'')+
(c.menorIdade?''+esc(c.repRelacao||'representado')+'':'')+
(c.area?''+esc(c.area)+'':'')+
(c.origem?''+esc(c.origem)+'':'')+
(c.estado?''+esc(c.estado)+'':'')+
'
';
}
function gpPintarAnexos(){
const cx = $('gpf-anexos'); if(cx) cx.innerHTML = gpAnexosHTML(gpAnexosTmp(), true);
}
function gpAnexarArquivo(ev){
const f = ev.target.files && ev.target.files[0]; if(!f) return;
if(f.size > ADM_MAX_ARQ){ toast('Arquivo acima de 3 MB. Anexe o link da pasta no Drive.'); ev.target.value=''; return; }
const r = new FileReader();
r.onload = () => { gpAnexosTmp().push({nome:f.name, dados:r.result, em:agoraISO(), porQuem:eu().nome});
gpPintarAnexos(); toast('Documento anexado ao registro.'); };
r.readAsDataURL(f); ev.target.value='';
}
async function gpAnexarLink(){
const u = await okrPerguntar('Link do documento', '');
if(!u || !u.trim()) return;
gpAnexosTmp().push({nome:u.trim().replace(/^https?:\/\//,'').slice(0,70), url:u.trim(),
em:agoraISO(), porQuem:eu().nome});
gpPintarAnexos(); toast('Link anexado ao registro.');
}
function gpTirarAnexo(i){ gpAnexosTmp().splice(i,1); gpPintarAnexos(); }
/* i vem do rascunho quando o formulário está aberto, ou do registro salvo. */
function gpBaixarAnexo(iReg, iTmp){
let a = null;
if(iTmp>=0) a = gpAnexosTmp()[iTmp];
else if(iReg>=0){ const st=admEstado(); a = (st.gp.anexoAlvo||[])[iReg]; }
if(a && a.dados) baixarDataUrl(a.nome||'arquivo', a.dados);
}
function gpVerAnexo(idReg, i){
const pes = gpPessoa(admEstado().gp.aberto); if(!pes) return;
const r = gpRegs(pes).find(x=>String(x.id)===String(idReg)); if(!r) return;
const a = (r.anexos||[])[i]; if(!a) return;
if(a.url){ window.open(linkHref(a.url), '_blank', 'noopener'); return; }
if(a.dados){ baixarDataUrl(a.nome||'arquivo', a.dados); return; }
if(a.ref){ anexoAbrir(a.nome||'arquivo', '', a.ref); return; }
toast('Este arquivo não está guardado no sistema.');
}
function gpRascunho(){
const st = admEstado();
return st.gp.editando
? Object.assign({}, gpRegs(gpPessoa(st.gp.aberto)).find(r=>String(r.id)===String(st.gp.editando))||{})
: (st.gp.novo||{});
}
function gpColetar(){
const g = id => { const el=$(id); return el ? el.value : ''; };
const st = admEstado(), base = gpRascunho();
return Object.assign({}, base, {
anexos: gpAnexosTmp().slice(),
tipo: base.tipo || 'feedback',
data: g('gpf-data'), titulo: (g('gpf-titulo')||'').trim(), texto: (g('gpf-texto')||'').trim(),
ate: g('gpf-ate') || '',
valor: $('gpf-valor') ? parseMoneySimples(g('gpf-valor')) : (base.valor!=null?base.valor:null),
valorAnterior: $('gpf-antes') ? parseMoneySimples(g('gpf-antes')) : (base.valorAnterior!=null?base.valorAnterior:null)
});
}
function gpMascaraMoeda(el){
const v = parseMoneySimples(el.value);
el.value = v==null ? '' : brlSimples(v);
}
function parseMoneySimples(v){
const t = String(v||'').replace(/[^\d,.-]/g,'').replace(/\./g,'').replace(',','.');
const n = parseFloat(t);
return isNaN(n) ? null : n;
}
function gpNovoReg(){
const st = admEstado();
st.gp.editando = null;
st.gp.anexosTmp = [];
st.gp.novo = {tipo:'feedback', data:iso(hoje0()), titulo:'', texto:'', anexos:[]};
gpDesenhar();
setTimeout(()=>{ const el=$('gpf-titulo'); if(el) el.focus(); }, 60);
}
function gpEditarReg(id){
const st = admEstado(); st.gp.novo=null; st.gp.editando=id;
const pes = gpPessoa(st.gp.aberto);
const r = pes ? gpRegs(pes).find(x=>String(x.id)===String(id)) : null;
st.gp.anexosTmp = (r && Array.isArray(r.anexos)) ? JSON.parse(JSON.stringify(r.anexos)) : [];
gpDesenhar();
}
function gpCancelarReg(){ const st=admEstado(); st.gp.novo=null; st.gp.editando=null; st.gp.anexosTmp=[]; gpDesenhar(); }
function gpTrocarTipo(k){
const st = admEstado(), atual = gpColetar();
atual.tipo = k;
if(st.gp.editando){
const pes = gpPessoa(st.gp.aberto), r = gpRegs(pes).find(x=>String(x.id)===String(st.gp.editando));
if(r) Object.assign(r, {tipo:k, data:atual.data, titulo:atual.titulo, texto:atual.texto});
} else st.gp.novo = atual;
gpDesenhar();
}
function gpSalvarReg(){
const st = admEstado(), pes = gpPessoa(st.gp.aberto); if(!pes) return;
const o = gpColetar();
const erro = m => { const b=$('gpf-err'); if(b){ b.textContent=m; b.classList.add('on'); } };
if(!o.data) return erro('Informe a data do registro.');
if(!o.titulo && !o.texto) return erro('Escreva ao menos um título ou o texto do registro.');
if(o.tipo==='aumento' && o.valor==null) return erro('Informe o novo valor da remuneração.');
if(st.gp.editando){
const r = gpRegs(pes).find(x=>String(x.id)===String(st.gp.editando));
if(r) Object.assign(r, o, {alteradoEm:agoraISO()});
} else {
gpRegs(pes).push(Object.assign({id:admId(), porQuem:eu().nome, criadoEm:agoraISO(), resolvido:false}, o));
}
st.gp.novo=null; st.gp.editando=null; st.gp.anexosTmp=[];
salvar(); gpDesenhar();
toast('Registro salvo no dossiê de '+primeiroNome(gpNomeDe(pes))+
(o.anexos && o.anexos.length ? ' com '+o.anexos.length+' documento(s).' : '.'));
}
function gpResolver(id){
const pes = gpPessoa(admEstado().gp.aberto); if(!pes) return;
const r = gpRegs(pes).find(x=>String(x.id)===String(id)); if(!r) return;
r.resolvido = !r.resolvido; r.alteradoEm = agoraISO();
salvar(); gpDesenhar();
toast(r.resolvido?'Marcado como resolvido.':'Reaberto.');
}
async function gpApagarReg(id){
if(!admPodeExcluir()){ toast('Só a Administração exclui registros.'); return; }
const pes = gpPessoa(admEstado().gp.aberto); if(!pes) return;
const r = gpRegs(pes).find(x=>String(x.id)===String(id)); if(!r) return;
if(!await confirmar('Excluir este registro?\n\n"'+(r.titulo||String(r.texto||'').slice(0,60))+'"\n\nEssa exclusão não pode ser desfeita.')) return;
pes.registros = gpRegs(pes).filter(x=>String(x.id)!==String(id));
salvar(); gpDesenhar(); toast('Registro excluído.');
}
function gpLigar(){
const mm=$('gp-mes'), ma=$('gp-ano');
if(mm) mm.onchange = e=>{ admEstado().gp.mes = +e.target.value; gpDesenhar(); };
if(ma) ma.onchange = e=>{ admEstado().gp.ano = +e.target.value; gpDesenhar(); };
['gpf-valor','gpf-antes'].forEach(id=>{ const el=$(id); if(el) el.addEventListener('blur',()=>gpMascaraMoeda(el)); });
}
/* ---------- a pasta em si: quem é, desde quando ---------- */
function gpNovaPessoa(){ gpFormPessoa(null); }
function gpEditarPessoa(){ gpFormPessoa(gpPessoa(admEstado().gp.aberto)); }
function gpFormPessoa(pes){
if(!admPode()){ toast('Menu restrito à Administração e aos sócios.'); return; }
admEstado().gp.edit = pes ? pes.id : null;
const jaTem = admPessoas().filter(p=>!pes || p.id!==pes.id).map(p=>p.membroId).filter(Boolean);
const livres = S.membros.filter(m=>jaTem.indexOf(m.id)<0);
$('gpp-titulo').textContent = pes ? gpNomeDe(pes) : 'Novo colaborador acompanhado';
$('gpp-corpo').innerHTML =
'
Os nomes vêm do cadastro em Configurações › Equipe. Se a pessoa '+
'ainda não tem acesso à plataforma — um prestador, alguém em processo de contratação — use o cadastro manual; '+
'nesse caso o painel automático do mês fica indisponível, porque não há tarefas para medir.
'+
'
'+
'
'+
'
'+
'
'+
'
'+
''+
'
'+
'
Usada para lembrar o aniversário de casa.
'+
'
'+
'
Só preencha aqui se a pessoa não tiver cadastro na plataforma.
'+
'
'+
'
Ponto de partida. Cada ajuste registrado depois atualiza este valor.
'+
'
'+
'
'+
'
'+
'';
gpTrocaVinculo();
const sal = $('gpp-sal'); if(sal) sal.addEventListener('blur',()=>gpMascaraMoeda(sal));
abrir('ov-gp-pessoa');
}
function gpTrocaVinculo(){
const sel = $('gpp-membro'); if(!sel) return;
const manual = !sel.value;
$('gpp-fnome').style.display = manual ? '' : 'none';
}
function gpSalvarPessoa(){
const st = admEstado();
const erro = m => { const b=$('gpp-err'); b.textContent=m; b.classList.add('on'); };
const mid = $('gpp-membro').value ? +$('gpp-membro').value : null;
const nome = ($('gpp-nome').value||'').trim();
if(!mid && !nome) return erro('Escolha um integrante da equipe ou informe o nome no cadastro manual.');
const o = {
membroId: mid, nome: mid ? '' : nome,
cargo: ($('gpp-cargo').value||'').trim(),
admissao: $('gpp-adm').value||'', nasc: $('gpp-nasc').value||'',
salario: parseMoneySimples($('gpp-sal').value),
obs: ($('gpp-obs').value||'').trim()
};
const alvo = st.gp.edit ? gpPessoa(st.gp.edit) : null;
if(alvo){ Object.assign(alvo, o, {alteradoEm:agoraISO()}); }
else {
const novo = Object.assign({id:admId(), registros:[], criadoPor:eu().nome, criadoEm:agoraISO()}, o);
admPessoas().push(novo);
st.gp.aberto = novo.id;
}
salvar(); fechar('ov-gp-pessoa');
if($('ov-gp').classList.contains('on')) gpDesenhar();
renderAdmin();
toast(alvo ? 'Pasta atualizada.' : 'Pasta criada. Registre o primeiro feedback quando houver.');
}
async function gpApagarPessoa(){
if(!admPodeExcluir()){ toast('Só a Administração remove pastas.'); return; }
const pes = gpPessoa(admEstado().gp.aberto); if(!pes) return;
const n = gpRegs(pes).length;
if(!await confirmar('Remover a pasta de '+gpNomeDe(pes)+'?'+
(n?'\n\nOs '+n+' registro(s) anotados vão junto.':'')+
'\n\nO cadastro da pessoa na equipe não é afetado.\n\nEssa exclusão não pode ser desfeita.')) return;
S.admPessoas = admPessoas().filter(p=>String(p.id)!==String(pes.id));
salvar(); fechar('ov-gp'); renderAdmin();
toast('Pasta removida.');
}
/* Cria de uma vez a pasta de quem já está no cadastro da equipe. */
async function gpTrazerEquipe(){
if(!admPode()) return;
const faltam = gpForaDaLista();
if(!faltam.length){ toast('Todo mundo do cadastro já tem pasta.'); return; }
if(!await confirmar('Criar a pasta de '+faltam.length+' integrante(s)?\n\n'+
faltam.map(m=>'· '+m.nome).join('\n')+'\n\nAs pastas nascem vazias, prontas para o primeiro registro.')) return;
faltam.forEach(m=>admPessoas().push({id:admId(), membroId:m.id, nome:'', cargo:'', admissao:'', nasc:'',
salario:null, obs:'', registros:[], criadoPor:eu().nome, criadoEm:agoraISO()}));
salvar(); renderAdmin();
toast(faltam.length+' pasta(s) criada(s).');
}
/* =========================================================
METAS — OKR
Objectives and Key Results. O objetivo diz PARA ONDE o
escritório vai, em linguagem de gente; os resultados-chave
dizem COMO saberemos que chegamos, em número.
Regra de ouro da metodologia, e o que a tela cobra o tempo
todo: objetivo é qualitativo e inspirador; resultado-chave é
quantitativo, com linha de base, alvo e prazo. Se não dá para
discutir se foi atingido olhando um número, não é um KR — é
uma tarefa.
Quem edita: só a Administração. Quem lê e comenta: também os
advogados sócios. Ninguém mais entra no menu Administrativo.
========================================================= */
function okrBase(){
if(!S.metas || typeof S.metas!=='object') S.metas = {};
if(!Array.isArray(S.metas.objetivos)) S.metas.objetivos = [];
if(!Array.isArray(S.metas.kpis)) S.metas.kpis = [];
if(!S.metas.cfg) S.metas.cfg = {};
if(!S.metas.lidos) S.metas.lidos = {};
return S.metas;
}
function okrObjetivos(){ return okrBase().objetivos; }
function okrKpis(){ return okrBase().kpis; }
function okrPodeEditar(){ return ehAdmin(); }
function okrPodeComentar(){ return admPode(); }
function okrObj(id){ return okrObjetivos().find(o=>String(o.id)===String(id)) || null; }
/* ---------- ciclos ---------- */
function okrCicloDe(d){
const x = d ? new Date(d) : new Date();
return x.getFullYear()+'-T'+(Math.floor(x.getMonth()/3)+1);
}
function okrCicloRot(c){
const m = String(c||'').match(/^(\d{4})-T([1-4])$/);
if(!m) return c||'—';
const nomes = {1:'jan–mar', 2:'abr–jun', 3:'jul–set', 4:'out–dez'};
return m[2]+'º trimestre de '+m[1]+' · '+nomes[+m[2]];
}
function okrCiclosDisponiveis(){
const set = new Set([okrCicloDe()]);
okrObjetivos().forEach(o=>{ if(o.ciclo) set.add(o.ciclo); });
const h = new Date();
for(let k=-2;k<=3;k++){ const d=new Date(h.getFullYear(), h.getMonth()+k*3, 1); set.add(okrCicloDe(d)); }
return [...set].sort();
}
function okrLimitesCiclo(c){
const m = String(c||'').match(/^(\d{4})-T([1-4])$/);
if(!m) return null;
const a = +m[1], t = +m[2];
return {ini:new Date(a,(t-1)*3,1), fim:new Date(a, t*3, 0)};
}
/* ---------- progresso de um resultado-chave ----------
O KR mede a distância entre onde estávamos (linha de base) e onde
queremos chegar (alvo). Serve tanto para subir (faturamento) quanto
para descer (tempo de resposta): a conta é a mesma. */
function okrProgKr(kr){
if(!kr) return 0;
if(kr.tipo==='marcos'){
const ms = kr.marcos||[];
if(!ms.length) return 0;
return Math.round(ms.filter(x=>x.ok).length / ms.length * 100);
}
const de = +(kr.de||0), para = +(kr.para||0), at = +(kr.atual!=null?kr.atual:de);
if(para === de) return at>=para ? 100 : 0;
const p = (at - de) / (para - de) * 100;
return Math.max(0, Math.min(100, Math.round(p)));
}
function okrProgObj(o){
const krs = (o && o.krs) || [];
if(!krs.length) return 0;
const pesoTotal = krs.reduce((a,k)=>a+(+k.peso||1),0);
const soma = krs.reduce((a,k)=>a + okrProgKr(k)*(+k.peso||1), 0);
return Math.round(soma/pesoTotal);
}
/* Quanto do prazo já passou — o "relógio" contra o qual o avanço é lido. */
function okrTempoDecorrido(o){
const ini = pd(o.inicio) || (okrLimitesCiclo(o.ciclo)||{}).ini;
const fim = pd(o.prazo) || (okrLimitesCiclo(o.ciclo)||{}).fim;
if(!ini || !fim || fim<=ini) return null;
const h = new Date();
return Math.max(0, Math.min(100, Math.round((h-ini)/(fim-ini)*100)));
}
function okrDiasRestantes(o){
const fim = pd(o.prazo) || (okrLimitesCiclo(o.ciclo)||{}).fim;
if(!fim) return null;
const h = new Date(); h.setHours(0,0,0,0);
return Math.round((fim - h)/86400000);
}
/* ---------- o semáforo ----------
Compara o avanço com o tempo já gasto. Um objetivo em 40% no meio do
trimestre está no ritmo; o mesmo 40% faltando uma semana, não. */
const OKR_SAUDE = {
concluido: {k:'concluido', rot:'Concluído', cor:'#127a52', bg:'#e6f4ee'},
adiantado: {k:'adiantado', rot:'Adiantado', cor:'#0f766e', bg:'#e3f4f2'},
noritmo: {k:'noritmo', rot:'No ritmo', cor:'#1D6FE0', bg:'#eaf2fd'},
atencao: {k:'atencao', rot:'Atrasado', cor:'#8a5a00', bg:'#fdf3e0'},
risco: {k:'risco', rot:'Em risco', cor:'#b3261e', bg:'#fdeceb'},
parado: {k:'parado', rot:'Sem medição',cor:'#6b7280', bg:'#f1f3f6'},
pausado: {k:'pausado', rot:'Pausado', cor:'#6b7280', bg:'#f1f3f6'}
};
function okrSaude(o){
if(o.status==='concluido') return OKR_SAUDE.concluido;
if(o.status==='pausado' || o.status==='cancelado') return OKR_SAUDE.pausado;
if(!(o.krs||[]).length) return OKR_SAUDE.parado;
const p = okrProgObj(o), t = okrTempoDecorrido(o);
if(p>=100) return OKR_SAUDE.concluido;
if(t==null) return OKR_SAUDE.noritmo;
const folga = p - t;
if(folga >= 12) return OKR_SAUDE.adiantado;
if(folga >= -8) return OKR_SAUDE.noritmo;
if(folga >= -25) return OKR_SAUDE.atencao;
return OKR_SAUDE.risco;
}
/* Formata o valor de um KR conforme o que ele mede. */
function okrValor(kr, v){
const n = +(v==null?0:v);
if(kr.tipo==='moeda') return brlSimples(n);
if(kr.tipo==='percentual')return (Math.round(n*10)/10)+'%';
if(kr.tipo==='marcos') return String(n);
const u = kr.unidade ? ' '+kr.unidade : '';
return (Math.round(n*100)/100).toLocaleString('pt-BR')+u;
}
/* ---------- capas ----------
Nenhuma imagem vem da internet: cada capa é um SVG desenhado aqui e
guardado como data URI. Assim a plataforma abre offline, a base não
engorda com fotos pesadas e a Administração escolhe uma capa em um
clique — sem sair para procurar imagem em banco nenhum.
Quem preferir a própria foto pode subir a dela do mesmo jeito. */
const OKR_CAPAS = [
{k:'aurora', n:'Aurora', c:['#1e3a5f','#2d5486','#7aa0cc'], f:'ondas'},
{k:'toga', n:'Toga', c:['#12283f','#1b3a5c','#8a6d1f'], f:'colunas'},
{k:'balanca', n:'Balança', c:['#5f4712','#8a6d1f','#e0cb93'], f:'raios'},
{k:'mata', n:'Mata', c:['#0d3b2e','#127a52','#5fbf95'], f:'ondas'},
{k:'brasa', n:'Brasa', c:['#7a1f16','#b3261e','#e07a3f'], f:'raios'},
{k:'indigo', n:'Índigo', c:['#241a5e','#4230a8','#8a7ce0'], f:'malha'},
{k:'oceano', n:'Oceano', c:['#0b2f45','#12607f','#4fb3c9'], f:'ondas'},
{k:'ametista',n:'Ametista', c:['#3d1d5c','#6d3fb5','#b79ae6'], f:'bolhas'},
{k:'cobre', n:'Cobre', c:['#5a2b12','#a85a2a','#e0a06a'], f:'colunas'},
{k:'sisal', n:'Sisal', c:['#4a4028','#8a7a4a','#d8caa0'], f:'malha'},
{k:'grafite', n:'Grafite', c:['#1a1d23','#3a4150','#8a93a3'], f:'malha'},
{k:'alvorada',n:'Alvorada', c:['#7a2f52','#c14f6f','#f0a58f'], f:'bolhas'},
{k:'jade', n:'Jade', c:['#0f3f3a','#0f766e','#6fd0c2'], f:'raios'},
{k:'noturno', n:'Noturno', c:['#0a1626','#16324f','#3f6b96'], f:'bolhas'},
{k:'ouro', n:'Ouro velho', c:['#463307','#8a6d1f','#f2d264'], f:'colunas'},
{k:'ardosia', n:'Ardósia', c:['#20262e','#44505f','#9fb0c2'], f:'ondas'}
];
/* Cada padrão é uma camada geométrica sobre o gradiente. */
function okrPadrao(f, c3){
const o = 'fill="'+c3+'" fill-opacity=';
if(f==='ondas')
return ''+
'';
if(f==='colunas'){
let h='';
for(let i=0;i<9;i++) h += '';
return h;
}
if(f==='raios'){
let h='';
for(let i=0;i<7;i++) h += '';
return h;
}
if(f==='malha'){
let h='';
for(let i=0;i<13;i++) h += '';
for(let j=0;j<6;j++) h += '';
return h;
}
/* bolhas */
const pts = [[140,110,86],[330,300,54],[560,140,120],[790,320,70],[880,90,44],[210,360,38]];
return pts.map((b,i)=>'').join('');
}
function okrCapaSvg(k){
const c = OKR_CAPAS.find(x=>x.k===k) || OKR_CAPAS[0];
const svg =
'';
/* parênteses e apóstrofos precisam sair codificados: o data URI viaja dentro
de um url(...) em atributo style, e um parêntese solto fecharia a função */
return 'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg)
.replace(/\(/g,'%28').replace(/\)/g,'%29').replace(/'/g,'%27');
}
function okrCapaDe(o){
if(o && o.capa && o.capa.tipo==='imagem' && o.capa.dados) return o.capa.dados;
return okrCapaSvg((o && o.capa && o.capa.chave) || 'aurora');
}
/* ---------- etiquetas ---------- */
var OKR_ETIQUETAS = ['Crescimento','Financeiro','Clientes','Processos internos','Equipe','Marketing',
'Tecnologia','Qualidade','Reputação','Previdenciário','Trabalhista'];
function okrCorEtiqueta(txt){
return admCorDe(txt, [...new Set(OKR_ETIQUETAS.concat(
okrObjetivos().reduce((a,o)=>a.concat(o.etiquetas||[]), [])))]);
}
function okrEtiquetaHTML(txt){
const c = okrCorEtiqueta(txt);
return ''+esc(txt)+'';
}
/* ---------- comentários e o alerta de leitura ----------
O sócio não edita o objetivo, mas sugere. Cada sugestão fica na
discussão do objetivo e acende um aviso no topo da aba para quem
ainda não a leu — some sozinho quando a pessoa abre e marca como
lida. */
function okrComentarios(o){ return Array.isArray(o.comentarios) ? o.comentarios : (o.comentarios=[]); }
function okrMarcaLeitura(){ const u=eu(); return u ? (okrBase().lidos[u.id]||'') : ''; }
function okrNaoLidos(){
const u = eu(); if(!u) return [];
const desde = okrMarcaLeitura();
const out = [];
okrObjetivos().forEach(o=>okrComentarios(o).forEach(c=>{
if(String(c.porId)===String(u.id)) return; /* o que eu mesma escrevi não me alerta */
if(desde && String(c.em) <= String(desde)) return;
out.push({o:o, c:c});
}));
return out.sort((a,b)=>String(b.c.em).localeCompare(String(a.c.em)));
}
function okrMarcarLido(){
const u = eu(); if(!u) return;
okrBase().lidos[u.id] = agoraISO();
salvar(); okrRender();
toast('Sugestões marcadas como lidas.');
}
/* ---------- histórico ---------- */
function okrLog(o, texto){
if(!Array.isArray(o.historico)) o.historico = [];
o.historico.unshift({em:agoraISO(), porQuem:eu().nome, o:texto});
if(o.historico.length>120) o.historico.length = 120;
}
/* =========================================================
VARREDURA DO SISTEMA
A aba não pergunta "o que você quer melhorar?" no vazio. Ela lê o
que já está na plataforma — tarefas, clientes, equipe, documentos,
pessoas — e devolve pontos concretos, cada um já com o número que
serve de linha de base e um resultado-chave pronto para copiar.
Nada de conselho genérico: se não há dado que sustente, o achado
nem aparece.
========================================================= */
function okrPct(a,b){ return b>0 ? Math.round(a/b*100) : 0; }
function okrVarredura(){
const A = [];
const add = (o) => A.push(o);
const T = (S.tarefas||[]);
const conc = T.filter(t=>t.status===CONCLUIDA && t.concluidoEm);
const hoje = hoje0();
const novo = (id, area, titulo, achado, porque, kr, prio) =>
add({id:id, area:area, titulo:titulo, achado:achado, porque:porque, kr:kr, prio:prio||2});
/* --- 1. pontualidade das entregas --- */
const comPrazo = conc.filter(t=>t.data);
if(comPrazo.length >= 5){
const fora = comPrazo.filter(t=>{ const p=pd(t.data), f=new Date(t.concluidoEm);
if(!p||isNaN(f)) return false; p.setHours(23,59,59,999); return f>p; });
const pctFora = okrPct(fora.length, comPrazo.length);
if(pctFora >= 10) novo('prazo','Processos internos',
'Entregar dentro do prazo combinado, sempre',
pctFora+'% das entregas concluídas saíram depois do prazo ('+fora.length+' de '+comPrazo.length+').',
'Prazo perdido é o risco mais caro de um escritório: gera perda de direito, responsabilidade civil e desgaste com o cliente. '+
'Como o número já existe na base, ele serve de linha de base honesta — não é meta inventada.',
{titulo:'Reduzir a taxa de entregas fora do prazo', tipo:'percentual', de:pctFora, para:Math.max(0, Math.round(pctFora*0.4)), unidade:'%'},
pctFora>=25?1:2);
}
/* --- 2. o que está vencido agora --- */
const abertas = T.filter(t=>t.status!==CONCLUIDA && t.data);
const vencidas = abertas.filter(t=>{ const p=pd(t.data); return p && p= 3) novo('vencidas','Processos internos',
'Zerar a fila de demandas vencidas',
vencidas.length+' demanda(s) em aberto já passaram do prazo.',
'Fila vencida é dívida operacional: ela cresce sozinha e come o tempo do mês seguinte. Um KR de estoque '+
'("de X para 0") é o mais fácil de acompanhar, porque o número aparece na tela todo dia.',
{titulo:'Zerar as demandas vencidas em aberto', tipo:'numero', de:vencidas.length, para:0, unidade:'demandas'}, 1);
/* --- 3. tempo médio de execução --- */
const comTempo = conc.filter(t=>t.criadoEm && t.concluidoEm);
if(comTempo.length >= 8){
const dias = comTempo.map(t=>(new Date(t.concluidoEm)-new Date(t.criadoEm))/86400000).filter(x=>x>=0);
const media = +(dias.reduce((a,b)=>a+b,0)/dias.length).toFixed(1);
if(media >= 5) novo('ciclo','Processos internos',
'Encurtar o tempo entre receber e entregar',
'Cada demanda leva, em média, '+media.toString().replace('.',',')+' dia(s) da criação até a conclusão.',
'Tempo de ciclo é o indicador que melhor traduz "o escritório está fluindo". Reduzi-lo costuma vir de tirar '+
'espera do meio do caminho — documento que não chega, revisão que não sai — e não de trabalhar mais rápido.',
{titulo:'Reduzir o tempo médio de execução', tipo:'numero', de:media, para:+(media*0.7).toFixed(1), unidade:'dias'}, 2);
}
/* --- 4. carteira de clientes --- */
const C = (S.clientes||[]).filter(c=>!c.inativo);
if(C.length){
const semContato = C.filter(c=>{
const ult = T.filter(t=>(t.clientes||[]).indexOf(c.id)>=0 && t.concluidoEm)
.map(t=>new Date(t.concluidoEm)).sort((a,b)=>b-a)[0];
return !ult || (hoje-ult)/86400000 > 90;
});
if(semContato.length >= 3) novo('contato','Clientes',
'Nenhum cliente sem notícias por mais de 90 dias',
semContato.length+' de '+C.length+' clientes ativos estão há mais de 90 dias sem nenhuma demanda concluída.',
'Silêncio prolongado é a principal causa de reclamação na OAB e de pedido de renúncia por parte do cliente. '+
'É também o custo mais barato de corrigir: um contato de rotina resolve.',
{titulo:'Reduzir clientes sem contato há mais de 90 dias', tipo:'numero',
de:semContato.length, para:0, unidade:'clientes'}, 1);
const incompletos = C.filter(c=>!c.tel || !c.email || !c.cpfCnpj);
if(incompletos.length >= 3) novo('cadastro','Qualidade',
'Ter a carteira inteira com cadastro completo',
okrPct(incompletos.length, C.length)+'% dos clientes ativos ('+incompletos.length+') estão sem telefone, e-mail ou documento.',
'Cadastro incompleto trava tudo depois: emissão de nota, procuração, contato de urgência, prova de vínculo. '+
'É um KR de qualidade que o escritório controla 100% — não depende do cliente responder, depende de perguntar.',
{titulo:'Reduzir cadastros incompletos na carteira ativa', tipo:'numero',
de:incompletos.length, para:0, unidade:'cadastros'}, 2);
const semDrive = C.filter(c=>!c.linkDrive && !c.semDrive);
if(semDrive.length >= 5) novo('drive','Qualidade',
'Toda pasta de cliente localizável em um clique',
semDrive.length+' cliente(s) ativos sem link da pasta no Drive.',
'A pasta é onde mora a prova. Sem o link no cadastro, a equipe gasta minutos procurando — e minutos, somados, '+
'viram o tempo de ciclo do item acima.',
{titulo:'Vincular a pasta do Drive de todos os clientes ativos', tipo:'numero',
de:semDrive.length, para:0, unidade:'clientes'}, 3);
/* concentração por área */
const porArea = {};
C.forEach(c=>{ const a=c.area||'—'; porArea[a]=(porArea[a]||0)+1; });
const chaves = Object.keys(porArea).sort((a,b)=>porArea[b]-porArea[a]);
if(chaves.length && C.length>=8){
const top = chaves[0], pctTop = okrPct(porArea[top], C.length);
if(pctTop >= 65) novo('conc','Crescimento',
'Reduzir a dependência de uma única área',
pctTop+'% da carteira ativa está em '+top+'.',
'Concentração é risco regulatório e de mercado: uma mudança de entendimento do INSS ou do TST muda o faturamento '+
'do escritório inteiro. Diversificar não é abandonar a área forte, é criar a segunda perna.',
{titulo:'Elevar a participação da segunda área na carteira', tipo:'percentual',
de:100-pctTop, para:Math.min(50, 100-pctTop+15), unidade:'%'}, 2);
}
/* contratos novos no trimestre */
const lim = okrLimitesCiclo(okrCicloDe());
if(lim){
const novos = C.filter(c=>{ const d=pd(c.dataContrato); return d && d>=lim.ini && d<=lim.fim; }).length;
novo('novos','Crescimento',
'Crescer a carteira com previsibilidade',
novos+' contrato(s) novo(s) fechado(s) neste trimestre, numa carteira ativa de '+C.length+'.',
'Crescimento sem número vira sensação. Fixar a quantidade de contratos do trimestre transforma marketing e '+
'indicação em algo que se acompanha semana a semana, e não só no fim do ano.',
{titulo:'Fechar contratos novos no trimestre', tipo:'numero',
de:novos, para:Math.max(novos+3, Math.round(novos*1.4)||5), unidade:'contratos'}, 3);
}
}
/* --- 5. equipe --- */
const ativos = S.membros.filter(m=>m.status==='ativo');
const semNasc = ativos.filter(m=>!m.nasc);
if(semNasc.length) novo('nasc','Equipe',
'Completar o cadastro da equipe',
semNasc.length+' integrante(s) ativo(s) sem data de nascimento cadastrada.',
'Detalhe pequeno com efeito grande: é o que permite à plataforma cumprimentar a pessoa no dia e à gestão lembrar '+
'das datas sem depender da memória de ninguém.',
{titulo:'Zerar cadastros de equipe incompletos', tipo:'numero', de:semNasc.length, para:0, unidade:'cadastros'}, 3);
const pastas = admPessoas();
const semPasta = ativos.filter(m=>!pastas.some(p=>p.membroId===m.id));
if(semPasta.length) novo('pasta','Equipe',
'Acompanhar formalmente cada pessoa da equipe',
semPasta.length+' integrante(s) ativo(s) ainda sem pasta na Gestão de Pessoas.',
'Promoção e aumento justos exigem histórico. Sem registro, a decisão acaba se apoiando na lembrança dos últimos '+
'trinta dias — que raramente é a mais representativa.',
{titulo:'Ter pasta de acompanhamento para toda a equipe ativa', tipo:'numero',
de:semPasta.length, para:0, unidade:'pessoas'}, 2);
const semFb = pastas.filter(p=>{ const f=gpUltimo(p,'feedback'); const m=f?gpMesesDesde(f.data):null;
return !f || m>=4; });
if(semFb.length >= 2) novo('feedback','Equipe',
'Feedback estruturado a cada trimestre',
semFb.length+' pessoa(s) sem feedback registrado nos últimos 4 meses.',
'Feedback trimestral é o que separa gestão de improviso. Como o registro já existe na Gestão de Pessoas, este KR '+
'se mede sozinho: basta contar as pastas com registro no período.',
{titulo:'Pessoas com feedback registrado no trimestre', tipo:'numero',
de:Math.max(0, pastas.length-semFb.length), para:pastas.length, unidade:'pessoas'}, 2);
/* --- 6. treinamento --- */
const tre = admTreinos();
const ultTre = tre.map(t=>pd(t.data)).filter(Boolean).sort((a,b)=>b-a)[0];
const mesesTre = ultTre ? Math.round((hoje-ultTre)/86400000/30) : null;
if(!tre.length || mesesTre>=3) novo('treino','Equipe',
'Manter um ritmo de treinamento interno',
tre.length ? 'Último treinamento registrado há cerca de '+mesesTre+' mês(es).' : 'Nenhum treinamento registrado ainda.',
'Treinamento é o único investimento que melhora todas as outras métricas ao mesmo tempo — prazo, qualidade, '+
'atendimento. Um por mês, curto e registrado, vale mais que um evento anual.',
{titulo:'Realizar treinamentos internos no trimestre', tipo:'numero', de:0, para:3, unidade:'treinamentos'}, 3);
/* --- 7. documentos vencidos --- */
const docs = admDocs();
const venc = docs.filter(d=>d.validade && d.validade < iso(hoje));
const perto = docs.filter(d=>d.validade && d.validade >= iso(hoje) &&
(pd(d.validade) - hoje)/86400000 <= 60);
if(venc.length || perto.length >= 2) novo('docs','Qualidade',
'Documentação da sociedade sempre válida',
(venc.length? venc.length+' documento(s) vencido(s)':'')+(venc.length&&perto.length?' e ':'')+
(perto.length? perto.length+' vencendo nos próximos 60 dias':'')+'.',
'Alvará, certidão e LTCAT vencidos travam contratação, licitação e convênio bem no dia em que são necessários. '+
'É um KR binário e barato: ou está tudo válido, ou não está.',
{titulo:'Zerar documentos vencidos no arquivo da sociedade', tipo:'numero',
de:venc.length, para:0, unidade:'documentos'}, venc.length?1:3);
/* --- 8. senhas sem dono claro --- */
const sen = admSenhas();
const soltas = sen.filter(x=>!x.compartilhado && !admEscolhidos(x).length && !x.soAdm);
if(sen.length >= 5 && soltas.length >= Math.ceil(sen.length*0.6)) novo('senhas','Tecnologia',
'Definir a visibilidade de cada acesso',
soltas.length+' de '+sen.length+' acessos ainda sem regra de compartilhamento definida.',
'Acesso sem regra tende a virar acesso combinado no corredor. Definir visibilidade item a item é a base de qualquer '+
'política de segurança — e a plataforma já tem os três níveis prontos.',
{titulo:'Acessos com visibilidade definida', tipo:'numero',
de:sen.length-soltas.length, para:sen.length, unidade:'acessos'}, 3);
/* --- 9. distribuição da carga --- */
if(conc.length >= 10 && ativos.length >= 2){
const porPessoa = {};
conc.forEach(t=>(t.responsaveis||[]).forEach(r=>{ porPessoa[r]=(porPessoa[r]||0)+1; }));
const vals = ativos.map(m=>porPessoa[m.id]||0);
const maxV = Math.max(...vals), minV = Math.min(...vals);
if(maxV >= 5 && maxV >= minV*3) novo('carga','Equipe',
'Distribuir melhor a carga entre a equipe',
'A pessoa com mais entregas concluiu '+maxV+'; a com menos, '+minV+'.',
'Concentração de trabalho cria dois problemas ao mesmo tempo: risco de gargalo em quem faz demais e ociosidade '+
'em quem faz de menos. O KR não é igualar — é reduzir a distância.',
{titulo:'Reduzir a diferença de entregas entre a maior e a menor carga', tipo:'numero',
de:maxV-minV, para:Math.max(2, Math.round((maxV-minV)*0.5)), unidade:'demandas'}, 2);
}
/* --- 10. o próprio painel de metas --- */
const semKr = okrObjetivos().filter(o=>o.status!=='concluido' && !(o.krs||[]).length);
if(semKr.length) novo('semkr','Processos internos',
'Todo objetivo com resultado-chave medível',
semKr.length+' objetivo(s) ativo(s) ainda sem nenhum resultado-chave.',
'Objetivo sem KR é desejo. A metodologia só funciona quando cada objetivo carrega de dois a cinco números que '+
'respondem sozinhos se chegamos lá.',
{titulo:'Objetivos ativos com pelo menos 2 resultados-chave', tipo:'numero',
de:okrObjetivos().filter(o=>(o.krs||[]).length>=2).length, para:okrObjetivos().length, unidade:'objetivos'}, 1);
/* já virou objetivo? então sai da lista de sugestões */
const jaUsados = okrObjetivos().map(o=>o.origem).filter(Boolean);
return A.filter(x=>jaUsados.indexOf(x.id)<0).sort((a,b)=>a.prio-b.prio);
}
/* =========================================================
SUGESTÕES DE METRIFICAÇÃO
Quando a Administração escreve um objetivo, a aba lê o texto e
oferece resultados-chave prontos para o assunto — com a explicação
de por que aquele número mede o que importa.
========================================================= */
const OKR_RECEITAS = [
{chave:['faturamento','receita','honorário','honorario','lucro','financeiro','caixa','faturar'],
tema:'Financeiro',
krs:[
{titulo:'Elevar o faturamento previsto do trimestre', tipo:'moeda', unidade:'',
pq:'Mede o resultado em competência, não em caixa: separa o que o escritório vendeu do que já recebeu.'},
{titulo:'Reduzir a inadimplência da carteira', tipo:'percentual', unidade:'%',
pq:'Faturar sem receber é aumentar risco, não receita. Vale acompanhar os dois lados no mesmo objetivo.'},
{titulo:'Aumentar o ticket médio por contrato', tipo:'moeda', unidade:'',
pq:'Cresce o resultado sem aumentar a carga de trabalho — normalmente o caminho mais saudável.'}
]},
{chave:['cliente','carteira','atendimento','satisfação','satisfacao','relacionamento','indicação','indicacao'],
tema:'Clientes',
krs:[
{titulo:'Reduzir clientes sem contato há mais de 60 dias', tipo:'numero', unidade:'clientes',
pq:'Traduz "atender melhor" em uma fila concreta, que a equipe consegue atacar nome a nome.'},
{titulo:'Elevar a nota média de satisfação coletada após o atendimento', tipo:'numero', unidade:'de 0 a 10',
pq:'Exige criar a coleta — e é justamente isso que torna a percepção do cliente gerenciável.'},
{titulo:'Aumentar contratos fechados por indicação', tipo:'numero', unidade:'contratos',
pq:'Indicação é o efeito visível da qualidade percebida; medir isso é medir reputação com um número.'}
]},
{chave:['prazo','produtividade','processo','rotina','fluxo','organiza','eficiência','eficiencia','agilidade'],
tema:'Processos internos',
krs:[
{titulo:'Reduzir a taxa de entregas fora do prazo', tipo:'percentual', unidade:'%',
pq:'A plataforma já calcula esse número por pessoa e por mês — o KR nasce com linha de base real.'},
{titulo:'Reduzir o tempo médio entre a criação e a conclusão da demanda', tipo:'numero', unidade:'dias',
pq:'Mostra onde há espera escondida. Costuma cair mais com organização do que com esforço.'},
{titulo:'Zerar as demandas vencidas em aberto ao fim de cada semana', tipo:'numero', unidade:'demandas',
pq:'KR de estoque é o mais fácil de acompanhar: o número aparece na tela todos os dias.'}
]},
{chave:['equipe','pessoa','time','colaborador','treinamento','clima','contrat','capacit'],
tema:'Equipe',
krs:[
{titulo:'Realizar treinamentos internos registrados no trimestre', tipo:'numero', unidade:'treinamentos',
pq:'Curto, mensal e registrado vale mais que um evento anual — e alimenta o histórico de cada pessoa.'},
{titulo:'Pessoas com feedback estruturado registrado no trimestre', tipo:'numero', unidade:'pessoas',
pq:'A Gestão de Pessoas já guarda esses registros: o KR se mede sozinho, sem planilha paralela.'},
{titulo:'Reduzir a diferença de carga entre quem entrega mais e quem entrega menos', tipo:'numero', unidade:'demandas',
pq:'Ataca gargalo e ociosidade ao mesmo tempo, sem transformar o painel numa competição.'}
]},
{chave:['marketing','redes','instagram','seguidor','anúncio','anuncio','tráfego','trafego','divulga','site','alcance'],
tema:'Marketing',
krs:[
{titulo:'Elevar o número de leads qualificados recebidos no mês', tipo:'numero', unidade:'leads',
pq:'Seguidor não paga honorário. Lead qualificado é o primeiro número da cadeia que termina em contrato.'},
{titulo:'Reduzir o custo por lead do tráfego pago', tipo:'moeda', unidade:'',
pq:'Liga a verba ao resultado. Sem esse número, aumentar o investimento é aposta, não decisão.'},
{titulo:'Aumentar a taxa de conversão de lead em contrato assinado', tipo:'percentual', unidade:'%',
pq:'Mede o atendimento comercial, não a mídia — e costuma ser o gargalo real.'}
]},
{chave:['tecnologia','sistema','plataforma','automa','digital','segurança','seguranca','dados','lgpd'],
tema:'Tecnologia',
krs:[
{titulo:'Acessos com visibilidade definida no Administrativo', tipo:'numero', unidade:'acessos',
pq:'Base de qualquer política de segurança, e totalmente sob controle do escritório.'},
{titulo:'Reduzir tarefas repetitivas feitas à mão por semana', tipo:'numero', unidade:'tarefas',
pq:'Automação vira número quando se conta o que deixou de ser feito na unha.'},
{titulo:'Concluir etapas do plano de adequação à LGPD', tipo:'marcos', unidade:'',
pq:'Projeto com entregas discretas cabe melhor em marcos do que em porcentagem inventada.'}
]},
{chave:['qualidade','erro','revisão','revisao','padrão','padrao','peça','peca','retrabalho'],
tema:'Qualidade',
krs:[
{titulo:'Reduzir peças devolvidas na revisão interna', tipo:'numero', unidade:'peças',
pq:'Retrabalho é custo invisível: aparece como hora gasta, não como erro.'},
{titulo:'Elevar a proporção de demandas de alta complexidade entregues no prazo', tipo:'percentual', unidade:'%',
pq:'Cruza qualidade com pontualidade — é onde o escritório costuma escorregar.'},
{titulo:'Padronizar modelos de peça por área', tipo:'marcos', unidade:'',
pq:'Entrega discreta e verificável: ou o modelo existe e está em uso, ou não.'}
]}
];
function okrSugestoesPara(texto){
const t = iaNorm(String(texto||''));
if(!t.trim()) return [];
const achou = OKR_RECEITAS.filter(r=>r.chave.some(k=>t.indexOf(iaNorm(k))>=0));
return achou.length ? achou : [];
}
/* O que a metodologia cobra de todo objetivo — a lista que aparece ao lado
do formulário e vira um selo verde quando o item está resolvido. */
function okrDiagnostico(o){
const krs = o.krs||[];
const numericos = krs.filter(k=>k.tipo!=='marcos');
const temBase = numericos.filter(k=>+k.para !== +k.de).length;
const verbosFracos = ['fazer','melhorar','continuar','manter','tentar','buscar','trabalhar','acompanhar','aumentar a qualidade'];
const tit = iaNorm(o.titulo||'');
return [
{ok: (o.titulo||'').trim().length >= 12,
bom:'O objetivo tem um enunciado claro.',
ruim:'Dê ao objetivo um enunciado de uma frase — algo que a equipe repetiria de cabeça.'},
{ok: !verbosFracos.some(v=>tit.indexOf(iaNorm(v))===0),
bom:'Começa com um verbo de resultado, não de atividade.',
ruim:'Comece por um verbo de resultado ("Tornar", "Reduzir", "Conquistar") em vez de um de esforço ("melhorar", "acompanhar").'},
{ok: krs.length >= 2,
bom:krs.length+' resultado(s)-chave definido(s).',
ruim:'Defina de 2 a 5 resultados-chave. Menos que isso não cerca o objetivo; mais que isso dispersa.'},
{ok: krs.length <= 5,
bom:'A quantidade de resultados-chave está no ponto.',
ruim:'Mais de cinco resultados-chave diluem o foco — escolha os que realmente decidem o objetivo.'},
{ok: !numericos.length || temBase === numericos.length,
bom:'Todos os resultados-chave têm linha de base e alvo diferentes.',
ruim:'Algum resultado-chave está com o mesmo número na linha de base e no alvo. Sem distância, não há o que medir.'},
{ok: !!(o.responsaveis||[]).length,
bom:'Há responsável definido.',
ruim:'Escolha ao menos um responsável. Objetivo de todo mundo costuma ser de ninguém.'},
{ok: !!o.prazo,
bom:'O prazo está definido.',
ruim:'Defina a data-limite. Sem prazo o objetivo não tem ritmo, e o semáforo não consegue avisar nada.'},
{ok: String(o.porque||'').trim().length >= 20,
bom:'O "por que isso importa" está escrito.',
ruim:'Escreva em uma ou duas linhas por que este objetivo importa agora. É o que sustenta a prioridade quando a semana aperta.'}
];
}
/* =========================================================
KPIs
O OKR tem começo e fim; o KPI não. Ele é o sinal vital que o
escritório acompanha sempre, dentro e fora do ciclo. Aqui eles vêm
em duas espécies: os que a própria plataforma calcula, sem ninguém
digitar nada, e os que a gestão alimenta à mão.
========================================================= */
const OKR_KPIS_AUTO = [
{k:'prazo', n:'Entregas no prazo', un:'%', dir:'sobe', meta:90,
ajuda:'Das demandas concluídas nos últimos 90 dias que tinham prazo, quantas saíram dentro dele.',
calc:()=>{ const lim=new Date(); lim.setDate(lim.getDate()-90);
const l=(S.tarefas||[]).filter(t=>t.status===CONCLUIDA&&t.concluidoEm&&t.data&&new Date(t.concluidoEm)>=lim);
if(!l.length) return null;
const ok=l.filter(t=>{ const p=pd(t.data); if(!p) return false; p.setHours(23,59,59,999);
return new Date(t.concluidoEm)<=p; }).length;
return okrPct(ok,l.length); }},
{k:'vencidas', n:'Demandas vencidas em aberto', un:'', dir:'desce', meta:0,
ajuda:'Quantas demandas já passaram do prazo e continuam sem conclusão. Estoque, não fluxo.',
calc:()=>{ const h=hoje0();
return (S.tarefas||[]).filter(t=>t.status!==CONCLUIDA&&t.data&&pd(t.data)&&pd(t.data){ const lim=new Date(); lim.setDate(lim.getDate()-90);
const l=(S.tarefas||[]).filter(t=>t.status===CONCLUIDA&&t.concluidoEm&&t.criadoEm&&new Date(t.concluidoEm)>=lim);
if(!l.length) return null;
const d=l.map(t=>(new Date(t.concluidoEm)-new Date(t.criadoEm))/86400000).filter(x=>x>=0);
return d.length ? +(d.reduce((a,b)=>a+b,0)/d.length).toFixed(1) : null; }},
{k:'concluidas', n:'Demandas concluídas no mês', un:'', dir:'sobe', meta:null,
ajuda:'Volume entregue no mês corrente, somando toda a equipe.',
calc:()=>{ const h=new Date();
return (S.tarefas||[]).filter(t=>{ if(t.status!==CONCLUIDA||!t.concluidoEm) return false;
const d=new Date(t.concluidoEm); return d.getMonth()===h.getMonth()&&d.getFullYear()===h.getFullYear(); }).length; }},
{k:'clientes', n:'Clientes ativos', un:'', dir:'sobe', meta:null,
ajuda:'Tamanho atual da carteira, sem os marcados como inativos.',
calc:()=>(S.clientes||[]).filter(c=>!c.inativo).length},
{k:'novos', n:'Contratos novos no trimestre', un:'', dir:'sobe', meta:null,
ajuda:'Clientes com data de contrato dentro do trimestre corrente.',
calc:()=>{ const lim=okrLimitesCiclo(okrCicloDe()); if(!lim) return null;
return (S.clientes||[]).filter(c=>{ const d=pd(c.dataContrato); return d&&d>=lim.ini&&d<=lim.fim; }).length; }},
{k:'contato', n:'Clientes sem contato há 90 dias', un:'', dir:'desce', meta:0,
ajuda:'Clientes ativos sem nenhuma demanda concluída nos últimos 90 dias.',
calc:()=>{ const C=(S.clientes||[]).filter(c=>!c.inativo); if(!C.length) return null; const h=hoje0();
return C.filter(c=>{ const u=(S.tarefas||[]).filter(t=>(t.clientes||[]).indexOf(c.id)>=0&&t.concluidoEm)
.map(t=>new Date(t.concluidoEm)).sort((a,b)=>b-a)[0];
return !u || (h-u)/86400000>90; }).length; }},
{k:'cadastro', n:'Cadastros completos', un:'%', dir:'sobe', meta:100,
ajuda:'Clientes ativos com telefone, e-mail e documento preenchidos.',
calc:()=>{ const C=(S.clientes||[]).filter(c=>!c.inativo); if(!C.length) return null;
return okrPct(C.filter(c=>c.tel&&c.email&&c.cpfCnpj).length, C.length); }},
{k:'feedbacks', n:'Pessoas com feedback no trimestre', un:'', dir:'sobe', meta:null,
ajuda:'Pastas da Gestão de Pessoas com feedback registrado nos últimos 90 dias.',
calc:()=>{ const P=admPessoas(); if(!P.length) return null;
return P.filter(p=>{ const f=gpUltimo(p,'feedback'); const m=f?gpMesesDesde(f.data):null;
return f && m!=null && m<=3; }).length; }}
];
function okrKpiAuto(k){ return OKR_KPIS_AUTO.find(x=>x.k===k) || null; }
/* Lê um KPI, seja ele automático ou manual, no mesmo formato. */
function okrLerKpi(kpi){
if(kpi.auto){
const d = okrKpiAuto(kpi.auto);
const v = d ? d.calc() : null;
return {valor:v, nome:kpi.nome||(d?d.n:'—'), unidade:d?d.un:'', dir:d?d.dir:'sobe',
meta:(kpi.meta!=null?kpi.meta:(d?d.meta:null)), ajuda:d?d.ajuda:'', automatico:true};
}
const h = kpi.hist||[];
const ult = h.length ? h[h.length-1].valor : (kpi.atual!=null?kpi.atual:null);
return {valor:ult, nome:kpi.nome, unidade:kpi.unidade||'', dir:kpi.dir||'sobe',
meta:(kpi.meta!=null?kpi.meta:null), ajuda:kpi.ajuda||'', automatico:false};
}
function okrKpiFmt(l, v){
if(v==null) return '—';
const n = Math.round(v*100)/100;
if(l.unidade==='%') return n+'%';
if(l.unidade==='R$') return brlSimples(n);
return n.toLocaleString('pt-BR') + (l.unidade?' '+l.unidade:'');
}
/* Verde quando o indicador está do lado bom da meta. */
function okrKpiCor(l){
if(l.valor==null || l.meta==null) return {cor:'var(--mut)', bg:'var(--soft2)', rot:'sem meta'};
const bom = l.dir==='sobe' ? l.valor>=l.meta : l.valor<=l.meta;
if(bom) return {cor:'#127a52', bg:'#e6f4ee', rot:'na meta'};
const dist = l.dir==='sobe' ? (l.meta?l.valor/l.meta:0) : (l.valor?l.meta/l.valor:0);
return dist>=0.75 ? {cor:'#8a5a00', bg:'#fdf3e0', rot:'perto'} : {cor:'#b3261e', bg:'#fdeceb', rot:'longe'};
}
/* =========================================================
GRÁFICOS
Desenhados em SVG puro: acompanham o tema claro e escuro, imprimem
bem e não dependem de nenhuma biblioteca externa.
========================================================= */
function okrRosca(pct, cor, tam, grossura){
const t = tam||132, g = grossura||14, r = (t-g)/2, c = t/2;
const vol = 2*Math.PI*r, cheio = vol*Math.max(0,Math.min(100,pct))/100;
return '';
}
/* Barra dupla: avanço real contra o tempo já decorrido. */
function okrBarraRitmo(prog, tempo, cor){
return '
';
}
/* Linha do histórico de um KPI ou de um KR. */
function okrSparkline(vals, cor, alt){
const a = alt||42;
if(!vals || vals.length<2) return '
sem série ainda
';
const min = Math.min(...vals), max = Math.max(...vals), amp = (max-min)||1;
const L = 100;
const pts = vals.map((v,i)=>[ (i/(vals.length-1))*L, a-2 - ((v-min)/amp)*(a-8) ]);
const d = pts.map((p,i)=>(i?'L':'M')+p[0].toFixed(2)+' '+p[1].toFixed(2)).join(' ');
const area = d+' L'+L+' '+a+' L0 '+a+' Z';
return '';
}
/* ---------------- Metas: a tela ---------------- */
function okrEstado(){ return admEstado().okr; }
function okrRender(){
const st = okrEstado(), f = admEstado().f.metas;
const ciclo = f.ciclo || okrCicloDe();
$('adm-nota').innerHTML =
'O norte do escritório, em OKR: cada objetivo diz para onde vamos, em uma frase que a equipe repete de '+
'cabeça; cada resultado-chave diz, em número, como saberemos que chegamos. A aba lê a própria plataforma para '+
'sugerir objetivos com linha de base real, cobra a metodologia enquanto você escreve e acompanha o avanço contra o '+
'tempo do ciclo. Só a Administração edita; os advogados sócios leem e deixam sugestões.';
const naoLidos = okrNaoLidos();
const alertas = okrAlertasGerais();
const abas = [
{k:'painel', n:'Painel', ic:'📊'},
{k:'mural', n:'Objetivos', ic:'🎯'},
{k:'kpis', n:'KPIs', ic:'📈'},
{k:'radar', n:'Radar de melhorias', ic:'🔎'}
];
let barra = '
'+abas.map(a=>
'').join('')+'
';
barra += 'Ciclo'+
'';
if(okrPodeEditar()) barra += '';
$('adm-filtros').innerHTML = barra;
let corpo = '';
if(naoLidos.length) corpo +=
'
💡'+
'
'+naoLidos.length+' sugestão(ões) nova(s) da sociedade'+
'
'+naoLidos.slice(0,3).map(x=>
'').join(' · ')+
(naoLidos.length>3?' e mais '+(naoLidos.length-3)+'':'')+'
'+
'
';
alertas.forEach(a=>{ corpo +=
'
'+a.ic+''+
'
'+esc(a.tit)+'
'+a.txt+'
'+
(a.acao||'')+'
'; });
corpo += st.aba==='painel' ? okrPainel(ciclo)
: st.aba==='mural' ? okrMural()
: st.aba==='kpis' ? okrTelaKpis()
: okrTelaRadar();
$('adm-corpo').innerHTML = corpo;
okrLigarTela();
}
function okrAba(k){ okrEstado().aba = k; renderAdmin(); }
function okrLigarTela(){
const b = document.getElementById('okr-busca');
if(b) b.oninput = e=>{ admEstado().f.metas.q = e.target.value; okrRender();
const n=document.getElementById('okr-busca'); if(n){ n.focus(); n.setSelectionRange(n.value.length,n.value.length); } };
}
/* ---------- alertas do topo ---------- */
function okrAlertasGerais(){
const A = [], f = admEstado().f.metas;
const ativos = okrObjetivos().filter(o=>o.status!=='concluido' && o.status!=='cancelado' &&
(!f.ciclo || o.ciclo===f.ciclo));
const perto = ativos.filter(o=>{ const d=okrDiasRestantes(o); return d!=null && d>=0 && d<=21; });
const vencidos = ativos.filter(o=>{ const d=okrDiasRestantes(o); return d!=null && d<0; });
const risco = ativos.filter(o=>['risco','atencao'].indexOf(okrSaude(o).k)>=0);
const semKr = ativos.filter(o=>!(o.krs||[]).length);
const semCheck = ativos.filter(o=>{
const ult = (o.krs||[]).reduce((m,k)=>{ const c=(k.checkins||[]); const u=c.length?c[c.length-1].data:'';
return u>m?u:m; }, '');
if(!ult) return (o.krs||[]).length>0;
return (hoje0() - (pd(ult)||hoje0()))/86400000 > 21;
});
if(vencidos.length) A.push({t:'urg', ic:'⏰', tit:vencidos.length+' objetivo(s) com prazo vencido',
txt:vencidos.map(o=>'').join(' · ')});
if(perto.length) A.push({t:'aviso', ic:'📅', tit:perto.length+' objetivo(s) na reta final',
txt:perto.map(o=>'').join(' · ')});
if(risco.length) A.push({t:'aviso', ic:'📉', tit:risco.length+' objetivo(s) andando devagar para o prazo',
txt:risco.map(o=>'').join(' · ')});
if(semKr.length) A.push({t:'aviso', ic:'🧮', tit:semKr.length+' objetivo(s) ainda sem resultado-chave',
txt:'Sem número, o objetivo não é acompanhável — a aba não consegue dizer se ele avançou. '+
semKr.map(o=>'').join(' · ')});
if(semCheck.length) A.push({t:'info', ic:'🕘', tit:semCheck.length+' objetivo(s) sem medição há mais de 3 semanas',
txt:'OKR morre por falta de check-in, não por falta de ambição. '+
semCheck.map(o=>'').join(' · ')});
return A;
}
/* ---------- painel do ciclo ---------- */
function okrPainel(ciclo){
const f = admEstado().f.metas;
const lista = okrObjetivos().filter(o=>!f.ciclo || o.ciclo===f.ciclo);
if(!lista.length) return okrVazio();
const ativos = lista.filter(o=>o.status!=='cancelado');
const geral = ativos.length ? Math.round(ativos.reduce((a,o)=>a+okrProgObj(o),0)/ativos.length) : 0;
const lim = okrLimitesCiclo(f.ciclo||ciclo);
const tempo = (()=>{ if(!lim) return null; const h=new Date();
return Math.max(0,Math.min(100,Math.round((h-lim.ini)/(lim.fim-lim.ini)*100))); })();
const porSaude = {};
ativos.forEach(o=>{ const k=okrSaude(o).k; porSaude[k]=(porSaude[k]||0)+1; });
const totKrs = ativos.reduce((a,o)=>a+((o.krs||[]).length),0);
const krsFeitos = ativos.reduce((a,o)=>a+((o.krs||[]).filter(k=>okrProgKr(k)>=100).length),0);
const cor = geral>=100?'#127a52' : (tempo!=null && geral=100 ? 'Ciclo cumprido. Vale registrar o aprendizado antes de abrir o próximo.'
: geral>=tempo+12 ? 'O escritório está à frente do relógio. Bom momento para elevar um alvo ou puxar um objetivo do próximo ciclo.'
: geral>=tempo-8 ? 'Avanço e tempo caminham juntos — é exatamente o que se espera no meio do ciclo.'
: geral>=tempo-25 ? 'O avanço ficou atrás do relógio. Escolha um resultado-chave para destravar esta semana, em vez de empurrar todos.'
: 'A distância entre o avanço e o tempo é grande. Ou o alvo estava fora do alcance, ou faltou dono e rotina de medição — vale revisar em reunião, não no fim do trimestre.';
const barras = Object.keys(OKR_SAUDE).filter(k=>porSaude[k]).map(k=>{
const s = OKR_SAUDE[k];
return '
'+topo.slice(0,6).map(o=>{
const s = okrSaude(o), p = okrProgObj(o);
return '';
}).join('')+'
'+
'
'+
(kpisDest.length ? '
'+
'
Sinais vitais
'+
'
'+kpisDest.map(okrKpiCard).join('')+'
' : '')+
'
';
}
function okrVazio(){
const sug = okrVarredura().slice(0,3);
return '
'+
'
🎯
'+
'
O norte ainda não foi escrito
'+
'
Um bom ciclo tem de dois a quatro objetivos — não mais que isso. Cada um com uma frase que a equipe '+
'repete de cabeça e de dois a cinco resultados-chave em número.
'+
(sug.length
? '
A varredura da plataforma já encontrou por onde começar:'+
sug.map(x=>'
'+esc(x.titulo)+''+esc(x.achado)+'
').join('')+
'
'
: '')+
(okrPodeEditar()?'':
'
Só a Administração cria objetivos.
')+
'
';
}
/* ---------- mural de objetivos ---------- */
function okrMural(){
const f = admEstado().f.metas;
let l = okrObjetivos().slice();
if(f.ciclo) l = l.filter(o=>o.ciclo===f.ciclo);
const q = iaNorm(f.q||'');
if(q) l = l.filter(o=>iaNorm([o.titulo,o.porque,(o.etiquetas||[]).join(' '),
(o.krs||[]).map(k=>k.titulo).join(' ')].join(' ')).indexOf(q)>=0);
const ordem = {risco:0, atencao:1, parado:2, noritmo:3, adiantado:4, concluido:5, pausado:6};
l.sort((a,b)=>(ordem[okrSaude(a).k]-ordem[okrSaude(b).k]) || okrProgObj(a)-okrProgObj(b));
return '
'+
''+
''+l.length+' objetivo(s)'+
'
'+
(l.length ? '
'+l.map(okrCardObj).join('')+'
' : okrVazio());
}
function okrCardObj(o){
const s = okrSaude(o), p = okrProgObj(o), t = okrTempoDecorrido(o), d = okrDiasRestantes(o);
const resp = (o.responsaveis||[]).map(id=>{ const m=membro(id); return m; }).filter(Boolean);
const krs = o.krs||[];
const nComent = okrComentarios(o).length;
return '';
}
/* ---------- KPIs ---------- */
function okrKpiCard(kpi){
const l = okrLerKpi(kpi), c = okrKpiCor(l);
const hist = kpi.auto ? (kpi.hist||[]) : (kpi.hist||[]);
const vals = hist.map(h=>+h.valor).filter(v=>!isNaN(v));
const alvo = l.meta!=null ? okrKpiFmt(l, l.meta) : null;
const liga = kpi.objetivoId ? okrObj(kpi.objetivoId) : null;
return '
';
}
function okrTelaKpis(){
const l = okrKpis();
const jaAuto = l.map(k=>k.auto).filter(Boolean);
const disp = OKR_KPIS_AUTO.filter(a=>jaAuto.indexOf(a.k)<0);
return '
KPI não é OKR. O objetivo tem começo e fim; o indicador '+
'acompanha o escritório o tempo todo, dentro e fora do ciclo. Um bom painel mistura os dois: o OKR puxa a mudança, '+
'o KPI garante que nada essencial piorou enquanto a mudança acontecia. '+
'Os marcados como automáticos são calculados pela própria plataforma a partir de tarefas, clientes e equipe — '+
'ninguém digita, e por isso ninguém discute o número.
'+
(l.length ? '
'+l.map(okrKpiCard).join('')+'
'
: '
Nenhum indicador acompanhado ainda.
')+
(okrPodeEditar()
? '
Acrescentar indicador
'+
(disp.length
? '
'+disp.map(a=>'').join('')+'
'
: '
Todos os indicadores automáticos já estão no painel.
')+
''
: '');
}
function okrAddAuto(k){
if(!okrPodeEditar()) return;
const d = okrKpiAuto(k); if(!d) return;
okrKpis().push({id:admId(), auto:k, nome:d.n, meta:d.meta, hist:[], criadoEm:agoraISO()});
salvar(); okrRender(); toast('Indicador “'+d.n+'” acrescentado ao painel.');
}
function okrKpiFoto(id){
const kpi = okrKpis().find(x=>String(x.id)===String(id)); if(!kpi) return;
const l = okrLerKpi(kpi);
if(l.valor==null){ toast('Ainda não há dados suficientes para uma leitura deste indicador.'); return; }
kpi.hist = kpi.hist||[];
const hoje = iso(hoje0());
const ja = kpi.hist.find(h=>h.data===hoje);
if(ja) ja.valor = l.valor; else kpi.hist.push({data:hoje, valor:l.valor});
if(kpi.hist.length>60) kpi.hist = kpi.hist.slice(-60);
salvar(); okrRender();
toast('Leitura de hoje guardada: '+okrKpiFmt(l, l.valor)+'.');
}
function okrKpiApagar(id){
if(!okrPodeEditar()) return;
S.metas.kpis = okrKpis().filter(x=>String(x.id)!==String(id));
salvar(); okrRender(); toast('Indicador retirado do painel.');
}
/* ---------- radar de melhorias ---------- */
function okrTelaRadar(){
const A = okrVarredura();
const areas = [...new Set(A.map(x=>x.area))];
return '
A plataforma leu tarefas, prazos, carteira de clientes, '+
'equipe, documentos e acessos e listou abaixo só o que tem número por trás. Cada achado já vem com a '+
'linha de base real e um resultado-chave pronto — clique em virar objetivo e a aba abre o formulário '+
'preenchido, faltando só o seu ajuste de ambição.
Sem descrição. Use Editar para contar o contexto: de onde veio este objetivo, o que já '+
'foi tentado, o que está fora do escopo.
')+
'
Leitura da plataforma
'+
'
'+
'
'+conselho.tit+'
'+
'
'+conselho.txt+'
'+
(conselho.acoes.length?'
'+conselho.acoes.map(a=>'
'+a+'
').join('')+'
':'')+
'
'+
'
Qualidade do objetivo segundo a metodologia OKR
'+
'
'+diag.map(x=>
'
'+(x.ok?'✓':'!')+''+esc(x.ok?x.bom:x.ruim)+'
').join('')+'
'+
(faltam.length && okrPodeEditar()
? '
'+faltam.length+' ponto(s) ainda em aberto. Nenhum deles impede o '+
'objetivo de existir — mas cada um resolvido é uma dúvida a menos na reunião de acompanhamento.
'
: '
Este objetivo cumpre todos os pontos da metodologia. '+
'O que sobra agora é rotina de check-in.
');
}
/* A leitura em prosa: o que está acontecendo e o que fazer a respeito. */
function okrConselho(o, p, t){
const krs = o.krs||[];
const acoes = [];
const parados = krs.filter(k=>okrProgKr(k)<=5);
const quase = krs.filter(k=>{const x=okrProgKr(k);return x>=80&&x<100;});
const feitos = krs.filter(k=>okrProgKr(k)>=100);
const semDono = krs.filter(k=>!k.responsavel);
const semMed = krs.filter(k=>!(k.checkins||[]).length);
if(o.status==='concluido')
return {tit:'Objetivo concluído.',
txt:'Vale registrar na discussão o que funcionou e o que atrapalhou — é esse texto que faz o próximo ciclo começar melhor que este.', acoes:[]};
if(!krs.length)
return {tit:'Este objetivo ainda não é medível.',
txt:'Sem resultado-chave, não há como dizer se ele avançou. Abra a aba Resultados-chave: a plataforma já '+
'sugere métricas para o tema deste objetivo, com linha de base tirada dos seus próprios dados.', acoes:[]};
if(quase.length) acoes.push(''+quase.length+' resultado(s)-chave passaram de 80%: fechar o que está quase pronto rende mais que abrir frente nova.');
if(parados.length) acoes.push(''+parados.length+' resultado(s)-chave ainda não saíram do lugar. Se um deles não avançou até aqui, pergunte se ele depende de alguém de fora — costuma ser esse o motivo.');
if(semDono.length) acoes.push(''+semDono.length+' resultado(s)-chave estão sem responsável nomeado. Número sem dono não é acompanhado, é lembrado.');
if(semMed.length) acoes.push(''+semMed.length+' resultado(s)-chave nunca receberam um check-in. Uma medição quinzenal, ainda que sem avanço, já muda o comportamento da equipe.');
if(feitos.length && feitos.length===krs.length) acoes.push('Todos os resultados-chave estão cumpridos — marque o objetivo como concluído em Editar.');
let tit, txt;
if(t==null){ tit='Falta o relógio.'; txt='Sem prazo definido, a aba não consegue dizer se '+p+'% é muito ou pouco. Defina a data-limite em Editar.'; }
else if(p>=t+12){ tit='Adiantado em relação ao prazo.'; txt='O avanço ('+p+'%) está à frente do tempo decorrido ('+t+'%). Ou o time engatou, ou o alvo foi conservador. Se for a segunda hipótese, elevar o alvo agora é mais honesto do que comemorar em dezembro.'; }
else if(p>=t-8){ tit='No ritmo.'; txt='Avanço de '+p+'% com '+t+'% do tempo gasto: exatamente o esperado. O risco daqui em diante é parar de medir — a maioria dos OKRs morre por falta de check-in, não por falta de ambição.'; }
else if(p>=t-25){ tit='Atrás do relógio.'; txt='O avanço ('+p+'%) ficou abaixo do tempo decorrido ('+t+'%). Escolha um resultado-chave para destravar nesta semana. Empurrar todos ao mesmo tempo é o que costuma travar de vez.'; }
else { tit='Em risco de não fechar o ciclo.'; txt='Com '+t+'% do prazo consumido e apenas '+p+'% de avanço, a conta não fecha só com esforço. Há três saídas honestas: reduzir o escopo, remanejar quem toca o objetivo, ou reconhecer que o alvo estava fora do alcance e recalibrá-lo — registrando o motivo, para o próximo ciclo aprender com este.'; }
return {tit:tit, txt:txt, acoes:acoes};
}
/* ---------- resultados-chave ---------- */
const OKR_TIPOS_KR = [
{k:'numero', n:'Número', ajuda:'Contagem simples: contratos, demandas, dias, pessoas.'},
{k:'percentual', n:'Percentual', ajuda:'Proporção sobre um total: taxa de conversão, entregas no prazo.'},
{k:'moeda', n:'Valor (R$)', ajuda:'Faturamento, ticket médio, custo por lead.'},
{k:'marcos', n:'Marcos', ajuda:'Projeto com entregas discretas: ou o marco foi cumprido, ou não. Use quando não existe número contínuo honesto.'}
];
function okrPainelKrs(o){
const st = okrEstado(), krs = o.krs||[];
let h = '
'+
'
Um bom objetivo tem de dois a cinco resultados-chave. Cada um '+
'precisa de linha de base (onde estamos), alvo (onde queremos chegar) e dono. '+
'Sem os três, o número vira placar sem juiz.
'+
(okrPodeEditar()?'':'')+
'
';
if(st.novoKr || st.krEdit) h += okrFormKr(st.krEdit ? krs.find(k=>String(k.id)===String(st.krEdit)) : null);
h += krs.length ? '
'+krs.map(k=>okrItemKr(o,k)).join('')+'
'
: (st.novoKr?'':'
Nenhum resultado-chave ainda.
');
/* sugestões de metrificação para o tema do objetivo */
const sug = okrSugestoesPara((o.titulo||'')+' '+(o.porque||'')+' '+(o.etiquetas||[]).join(' '));
if(sug.length && okrPodeEditar()){
h += '
Como este objetivo pode ser metrificado
'+
'
A plataforma reconheceu o tema deste objetivo e separou métricas que costumam funcionar. '+
'Clique para inserir o resultado-chave já com o tipo certo — depois é só ajustar linha de base e alvo.
'+
sug.map(r=>'
'+esc(r.tema)+'
'+
r.krs.map((k,i)=>'
'+esc(k.titulo)+''+
''+esc(k.pq)+'
'+
'
').join('')+
'
').join('');
}
return h;
}
function okrUsarSugestao(tema, i){
const r = OKR_RECEITAS.find(x=>x.tema===tema); if(!r) return;
const k = r.krs[i]; if(!k) return;
const rascunho = {titulo:k.titulo, tipo:k.tipo, unidade:k.unidade||'', de:0,
para:(k.tipo==='percentual'?100:10)};
const st = okrEstado();
st.painel = 'krs'; st.krEdit = null; st.novoKr = rascunho;
okrDesenhar();
setTimeout(()=>{ const el=$('kr-de')||$('kr-marcos'); if(el) el.focus(); }, 60);
}
function okrItemKr(o, k){
const p = okrProgKr(k), s = okrSaude(o);
const m = k.responsavel ? membro(k.responsavel) : null;
const ult = (k.checkins||[]).slice(-1)[0];
const serie = (k.checkins||[]).map(c=>+c.valor).filter(v=>!isNaN(v));
const cor = p>=100?'#127a52' : p>=60?'#1D6FE0' : p>=25?'#8a5a00' : '#b3261e';
return '
'+
'
'+esc(k.titulo||'—')+''+
''+p+'%
'+
'
'+
(k.tipo==='marcos'
? '
'+((k.marcos||[]).length
? k.marcos.map((mm,i)=>'').join('')
: 'Nenhum marco cadastrado.')+'
'
: '
base '+okrValor(k,k.de)+''+
'hoje '+okrValor(k,(k.atual!=null?k.atual:k.de))+''+
'alvo '+okrValor(k,k.para)+'
'+
campo('full','Resultado-chave',true,'')+
(tipo==='marcos'
? campo('full','Marcos, um por linha',false,'',null,
'A conclusão é a fração de marcos cumpridos.')
: campo('','Linha de base',true,'',null,
'O número de partida. Se você não sabe, medir isso já é a primeira tarefa.')+
campo('','Alvo',true,'')+
campo('','Valor atual',false,'')+
(tipo==='numero'?campo('','Unidade',false,''):''))+
campo('','Responsável',false,'')+
campo('','Peso no objetivo',false,'',null,
'Use com parcimônia: peso diferente muda a conta do avanço do objetivo.')+
'
'+
''+
'
'+
'
'+
'
';
}
function okrNovoKr(){ const st=okrEstado(); st.krEdit=null; st.novoKr={tipo:'numero',de:0,para:10}; okrDesenhar();
setTimeout(()=>{ const el=$('kr-titulo'); if(el) el.focus(); },60); }
function okrEditarKr(id){ const st=okrEstado(); st.novoKr=false; st.krEdit=id; okrDesenhar(); }
function okrCancelarKr(){ const st=okrEstado(); st.novoKr=false; st.krEdit=null; okrDesenhar(); }
function okrColetarKr(){
const g = id => { const el=$(id); return el?el.value:''; };
const st = okrEstado();
const base = st.krEdit ? (okrObj(st.aberto).krs||[]).find(k=>String(k.id)===String(st.krEdit)) : (st.novoKr||{});
const tipo = (base && base.tipo) || 'numero';
const num = v => { const n=parseFloat(String(v).replace(/\./g,'').replace(',','.')); return isNaN(n)?null:n; };
const o = Object.assign({}, base, {
tipo: tipo, titulo:(g('kr-titulo')||'').trim(),
responsavel: g('kr-resp') ? +g('kr-resp') : null,
peso: +(g('kr-peso')||1)
});
if(tipo==='marcos'){
const antigos = base.marcos||[];
o.marcos = String(g('kr-marcos')||'').split('\n').map(x=>x.trim()).filter(Boolean).map(t=>{
const ja = antigos.find(m=>m.t===t); return {t:t, ok: ja?!!ja.ok:false}; });
} else {
o.de = num(g('kr-de')); o.para = num(g('kr-para'));
o.atual = g('kr-atual')!=='' ? num(g('kr-atual')) : o.de;
if($('kr-un')) o.unidade = (g('kr-un')||'').trim();
}
return o;
}
function okrSalvarKr(){
if(!okrPodeEditar()) return;
const st = okrEstado(), o = okrObj(st.aberto); if(!o) return;
const k = okrColetarKr();
const erro = m => { const b=$('kr-err'); if(b){ b.textContent=m; b.classList.add('on'); } };
if(!k.titulo) return erro('Escreva o resultado-chave.');
if(k.tipo==='marcos'){ if(!(k.marcos||[]).length) return erro('Liste ao menos um marco.'); }
else {
if(k.de==null || k.para==null) return erro('Preencha a linha de base e o alvo.');
if(k.de===k.para) return erro('Linha de base e alvo iguais: sem distância, não há o que medir.');
}
o.krs = o.krs||[];
if(st.krEdit){ const i=o.krs.findIndex(x=>String(x.id)===String(st.krEdit)); if(i>=0) o.krs[i]=k;
okrLog(o,'editou o resultado-chave “'+k.titulo+'”'); }
else { k.id = admId(); k.checkins = k.checkins||[]; o.krs.push(k);
okrLog(o,'acrescentou o resultado-chave “'+k.titulo+'”'); }
st.novoKr=false; st.krEdit=null;
salvar(); okrDesenhar(); toast('Resultado-chave salvo.');
}
function okrTipoKr(t){
const st = okrEstado();
const atual = okrColetarKr();
atual.tipo = t;
if(st.krEdit){ const o=okrObj(st.aberto); const i=(o.krs||[]).findIndex(x=>String(x.id)===String(st.krEdit));
if(i>=0) o.krs[i]=Object.assign(o.krs[i],{tipo:t}); }
else st.novoKr = atual;
okrDesenhar();
}
async function okrApagarKr(id){
if(!okrPodeEditar()) return;
const o = okrObj(okrEstado().aberto); if(!o) return;
const k = (o.krs||[]).find(x=>String(x.id)===String(id)); if(!k) return;
if(!await confirmar('Excluir o resultado-chave “'+k.titulo+'”?\n\nAs medições registradas nele vão junto.')) return;
o.krs = o.krs.filter(x=>String(x.id)!==String(id));
okrLog(o,'excluiu o resultado-chave “'+k.titulo+'”');
salvar(); okrDesenhar(); toast('Resultado-chave excluído.');
}
function okrMarco(id, i){
if(!okrPodeEditar()) return;
const o = okrObj(okrEstado().aberto); if(!o) return;
const k = (o.krs||[]).find(x=>String(x.id)===String(id)); if(!k || !k.marcos || !k.marcos[i]) return;
k.marcos[i].ok = !k.marcos[i].ok;
okrLog(o, (k.marcos[i].ok?'cumpriu':'reabriu')+' o marco “'+k.marcos[i].t+'”');
salvar(); okrDesenhar();
}
/* Check-in: a medição periódica que mantém o OKR vivo. */
function okrCheckin(id){
if(!okrPodeEditar()) return;
const o = okrObj(okrEstado().aberto); if(!o) return;
const k = (o.krs||[]).find(x=>String(x.id)===String(id)); if(!k) return;
okrEstado().checkin = id;
$('okc-titulo').textContent = k.titulo;
$('okc-corpo').innerHTML =
'
Registrar o número de hoje, mesmo quando ele não mudou. É a medição '+
'periódica — e não a ambição do alvo — que faz um OKR chegar ao fim do ciclo. '+
'Base '+okrValor(k,k.de)+' · atual '+okrValor(k,(k.atual!=null?k.atual:k.de))+' · alvo '+okrValor(k,k.para)+'.
'+
'
'+
campo('','Data',true,'')+
campo('','Valor de hoje',true,'')+
campo('full','O que explica esse número',false,'')+
'
';
abrir('ov-okr-check');
setTimeout(()=>{ const el=$('okc-valor'); if(el){ el.focus(); el.select(); } }, 60);
}
function okrSalvarCheckin(){
const st = okrEstado(), o = okrObj(st.aberto); if(!o) return;
const k = (o.krs||[]).find(x=>String(x.id)===String(st.checkin)); if(!k) return;
const n = parseFloat(String($('okc-valor').value).replace(/\./g,'').replace(',','.'));
if(isNaN(n)){ toast('Informe o valor medido.'); return; }
k.checkins = k.checkins||[];
const d = $('okc-data').value || iso(hoje0());
const nota = ($('okc-nota').value||'').trim();
const ja = k.checkins.find(c=>c.data===d);
if(ja){ ja.valor=n; ja.nota=nota; ja.porQuem=eu().nome; }
else k.checkins.push({data:d, valor:n, nota:nota, porQuem:eu().nome});
k.checkins.sort((a,b)=>String(a.data).localeCompare(String(b.data)));
k.atual = n;
okrLog(o,'mediu “'+k.titulo+'”: '+okrValor(k,n)+(nota?' — '+nota:''));
salvar(); fechar('ov-okr-check'); okrDesenhar();
toast('Medição registrada: '+okrValor(k,n)+'.');
}
/* ---------- discussão ---------- */
function okrPainelDiscussao(o){
const cs = okrComentarios(o).slice().sort((a,b)=>String(b.em).localeCompare(String(a.em)));
const desde = okrMarcaLeitura();
return '
Aqui os advogados sócios deixam sugestões sem alterar o '+
'objetivo. Toda sugestão nova acende um aviso no topo da aba Metas até ser lida — é assim que a estratégia '+
'continua sendo conversa, e não decreto.
'+
(okrPodeComentar()
? '
'+
'
'+
''+
''+
(okrPodeEditar()?'':'')+
'
'+
''+
''+
'
'
: '')+
(cs.length
? '
'+cs.map(c=>{
const novo = desde && String(c.em)>String(desde) && String(c.porId)!==String((eu()||{}).id);
const ic = c.tipo==='sugestao'?'💡':c.tipo==='decisao'?'⚖️':'💬';
const rot = c.tipo==='sugestao'?'Sugestão':c.tipo==='decisao'?'Decisão registrada':'Comentário';
return '
Resposta da Administração: '+esc(c.resposta).replace(/\n/g,' ')+'
':'')+
(okrPodeEditar()
? '
'+
(c.tipo==='sugestao'&&!c.virou
?'':'')+
''+
'
'
: '')+
'
';
}).join('')+'
'
: '
Nenhuma sugestão ainda.
');
}
function okrTipoComent(t){
okrEstado().tipoComent = t;
['sug','com','dec'].forEach(k=>{ const el=document.getElementById('okr-t-'+k); if(el) el.classList.remove('on'); });
const alvo = t==='sugestao'?'sug':t==='decisao'?'dec':'com';
const el = document.getElementById('okr-t-'+alvo); if(el) el.classList.add('on');
const c = document.getElementById('okr-coment'); if(c) c.focus();
}
function okrComentar(){
if(!okrPodeComentar()) return;
const o = okrObj(okrEstado().aberto); if(!o) return;
const el = $('okr-coment'); const txt = (el?el.value:'').trim();
if(!txt){ toast('Escreva a sugestão antes de publicar.'); return; }
okrComentarios(o).push({id:admId(), tipo:okrEstado().tipoComent||'sugestao', texto:txt,
porQuem:eu().nome, porId:eu().id, em:agoraISO()});
okrLog(o,'deixou uma '+(okrEstado().tipoComent==='decisao'?'decisão registrada':'sugestão'));
salvar(); okrDesenhar();
toast(ehAdmin()?'Publicado.':'Sugestão publicada — a Administração recebe o aviso ao abrir a aba Metas.');
}
async function okrResponder(id){
const o = okrObj(okrEstado().aberto); if(!o) return;
const c = okrComentarios(o).find(x=>String(x.id)===String(id)); if(!c) return;
const r = await okrPerguntar('Responder a '+c.porQuem, c.resposta||'');
if(r===null) return;
c.resposta = r.trim(); c.respondidoEm = agoraISO();
salvar(); okrDesenhar(); toast('Resposta registrada.');
}
async function okrApagarComent(id){
if(!okrPodeEditar()) return;
const o = okrObj(okrEstado().aberto); if(!o) return;
if(!await confirmar('Excluir esta mensagem da discussão?')) return;
o.comentarios = okrComentarios(o).filter(x=>String(x.id)!==String(id));
salvar(); okrDesenhar(); toast('Mensagem excluída.');
}
function okrSugVirarKr(id){
const o = okrObj(okrEstado().aberto); if(!o) return;
const c = okrComentarios(o).find(x=>String(x.id)===String(id)); if(!c) return;
c.virou = true;
okrEstado().krEdit = null;
okrEstado().novoKr = {tipo:'numero', titulo:String(c.texto).slice(0,90), de:0, para:10};
salvar(); okrPainelAba('krs');
toast('Sugestão de '+c.porQuem+' aberta como resultado-chave — ajuste a linha de base e o alvo.');
}
/* Uma caixinha de texto simples, no padrão da plataforma. */
function okrPerguntar(titulo, valor){
return new Promise(res=>{
$('okp-titulo').textContent = titulo;
$('okp-corpo').innerHTML = '';
OKR_PERG = res;
abrir('ov-okr-perg');
setTimeout(()=>{ const el=$('okp-txt'); if(el){ el.focus(); } }, 60);
});
}
var OKR_PERG = null;
function okrPergOk(){ const v=$('okp-txt')?$('okp-txt').value:''; fechar('ov-okr-perg');
if(OKR_PERG){ const r=OKR_PERG; OKR_PERG=null; r(v); } }
function okrPergCancelar(){ fechar('ov-okr-perg'); if(OKR_PERG){ const r=OKR_PERG; OKR_PERG=null; r(null); } }
/* ---------- material anexado ---------- */
function okrPainelMaterial(o){
const an = o.anexos||[];
return '
Documentos que sustentam o objetivo: a planilha da linha de base, '+
'a ata em que ele foi decidido, o orçamento do fornecedor. Até 3 MB por arquivo — para arquivos maiores, cole o '+
'link da pasta no Drive.
');
}
/* ---------------- criar e editar o objetivo ---------------- */
function okrNovoObjetivo(pre){
if(!okrPodeEditar()){ toast('Só a Administração cria objetivos.'); return; }
okrFormObjetivo(null, pre||null);
}
function okrFormObjetivo(id, pre){
if(!okrPodeEditar()){ toast('Só a Administração edita objetivos.'); return; }
const st = okrEstado();
const o = id ? okrObj(id) : null;
const e = o || Object.assign({ciclo:okrCicloDe(), status:'ativo', capa:{tipo:'padrao',chave:'aurora'}}, pre||{});
st.edit = id || null;
st.capaTmp = e.capa ? JSON.parse(JSON.stringify(e.capa)) : {tipo:'padrao', chave:'aurora'};
st.preKrs = (!id && pre && pre.krs) ? pre.krs : null;
st.origem = (!id && pre && pre.origem) ? pre.origem : null;
const lim = okrLimitesCiclo(e.ciclo) || okrLimitesCiclo(okrCicloDe());
$('oke-titulo').textContent = o ? 'Editar objetivo' : 'Novo objetivo';
const usadas = [...new Set(OKR_ETIQUETAS.concat(okrObjetivos().reduce((a,x)=>a.concat(x.etiquetas||[]),[])))];
$('oke-corpo').innerHTML =
'
'+
'
'+
'capa do objetivo
'+
'
'+
'
Escolha uma capa — todas são desenhadas pela própria plataforma, '+
'não precisam de internet nem de download. Ou suba a sua.
'+
'
'+OKR_CAPAS.map(c=>
'').join('')+'
'+
'
'+
''+
(st.capaTmp.tipo==='imagem'?'':'')+
'
'+
'
'+
'
'+
'
'+
campo('full','Objetivo',true,'',null,
'Qualitativo e memorável. Comece por um verbo de resultado — Tornar, Reduzir, Conquistar, Zerar — e não por um de esforço.')+
campo('full','Por que isso importa agora',false,'')+
campo('','Ciclo',true,'')+
campo('','Início',false,'')+
campo('','Prazo',true,'')+
campo('','Situação',false,'')+
campo('full','Responsáveis',false, msHTML('oke-resp','Digite o nome do integrante…'), null,
'Objetivo de todo mundo costuma ser de ninguém. Escolha quem responde por ele.')+
campo('full','Etiquetas',false, okrTagsHTML(usadas), null,
'Servem para agrupar objetivos por frente. Clique nas sugestões ou escreva a sua e tecle Enter.')+
campo('full','Descrição',false, edHTML('oke-desc', e.descricao||'',
'Contexto, escopo, o que está fora do escopo, o que já foi tentado…'))+
'
'+
'
Como a plataforma está lendo este objetivo
'+
''+
'';
msInit('oke-resp', pessoasCampo(), (e.responsaveis||[]));
msEuAtivar('oke-resp');
st.tagsTmp = (e.etiquetas||[]).slice();
okrTagsPintar(usadas);
$('oke-excluir').style.display = (o && okrPodeEditar()) ? '' : 'none';
okrCoach();
abrir('ov-okr-edit');
setTimeout(()=>{ const el=$('oke-titulo-i'); if(el && !id) el.focus(); }, 60);
}
function okrEscolherCapa(k){
const st = okrEstado();
st.capaTmp = {tipo:'padrao', chave:k};
const pv = $('oke-capaprev'); if(pv) pv.style.backgroundImage = 'url('+okrCapaSvg(k)+')';
document.querySelectorAll('.okr-cp').forEach(b=>b.classList.remove('on'));
const alvo = [...document.querySelectorAll('.okr-cp')][OKR_CAPAS.findIndex(c=>c.k===k)];
if(alvo) alvo.classList.add('on');
const rm = document.querySelector('.okr-capalista .lnk'); if(rm) rm.remove();
}
function okrCapaArquivo(ev){
const f = ev.target.files && ev.target.files[0]; if(!f) return;
if(f.size > 4*1024*1024){ toast('Imagem acima de 4 MB. Escolha uma menor.'); ev.target.value=''; return; }
const r = new FileReader();
r.onload = () => redimensionar(r.result, 1200, d=>{
okrEstado().capaTmp = {tipo:'imagem', dados:d};
const pv = $('oke-capaprev'); if(pv) pv.style.backgroundImage = 'url('+d+')';
document.querySelectorAll('.okr-cp').forEach(b=>b.classList.remove('on'));
toast('Capa trocada pela sua imagem.');
});
r.readAsDataURL(f); ev.target.value='';
}
function okrCicloMudou(){
const c = $('oke-ciclo').value, lim = okrLimitesCiclo(c);
if(!lim) return;
if(!$('oke-inicio').value) $('oke-inicio').value = iso(lim.ini);
$('oke-prazo').value = iso(lim.fim);
okrCoach();
}
/* O coach: lê o que está sendo escrito e responde na hora. */
function okrCoach(){
const cx = $('oke-coach'); if(!cx) return;
const tit = $('oke-titulo-i') ? $('oke-titulo-i').value : '';
const pq = $('oke-porque') ? $('oke-porque').value : '';
const st = okrEstado();
const provisorio = {titulo:tit, porque:pq, krs:(st.preKrs||[]), responsaveis:msSel('oke-resp'),
prazo:($('oke-prazo')?$('oke-prazo').value:'')};
const diag = okrDiagnostico(provisorio);
const sug = okrSugestoesPara(tit+' '+pq+' '+(okrEstado().tagsTmp||[]).join(' '));
cx.innerHTML =
'
'+diag.map(x=>'
'+(x.ok?'✓':'!')+''+
esc(x.ok?x.bom:x.ruim)+'
').join('')+'
'+
(sug.length
? '
Este objetivo parece ser de '+esc(sug.map(s=>s.tema).join(' e '))+'. '+
'Métricas que costumam funcionar aqui:'+
sug.slice(0,2).map(r=>r.krs.map(k=>'
'+esc(k.titulo)+''+esc(k.pq)+'
').join('')).join('')+
'
Depois de salvar, a aba Resultados-chave insere qualquer uma '+
'dessas com um clique — e a linha de base vem dos seus próprios dados sempre que a plataforma souber calculá-la.
'
: (tit.trim().length>6
? '
Não reconheci um tema conhecido neste enunciado. Isso não é problema: '+
'escreva os resultados-chave do seu jeito, lembrando que cada um precisa de linha de base, alvo e prazo.
'
: ''))+
(st.preKrs && st.preKrs.length
? '
Este objetivo já nasce com um resultado-chave, vindo do radar:'+
st.preKrs.map(k=>'
'+esc(k.titulo)+''+
'de '+okrValor(k,k.de)+' para '+okrValor(k,k.para)+' — linha de base tirada da sua própria base.
').join('')+
'
'
: '');
}
function okrSalvarObjetivo(){
if(!okrPodeEditar()) return;
const st = okrEstado();
const erro = m => { const b=$('oke-err'); b.textContent=m; b.classList.add('on'); };
const titulo = ($('oke-titulo-i').value||'').trim();
if(!titulo) return erro('Escreva o objetivo.');
const o = {
titulo: titulo,
porque: ($('oke-porque').value||'').trim(),
descricao: edValor('oke-desc'),
ciclo: $('oke-ciclo').value,
inicio: $('oke-inicio').value||'',
prazo: $('oke-prazo').value||'',
status: $('oke-status').value,
responsaveis: msSel('oke-resp'),
etiquetas: (okrEstado().tagsTmp||[]).slice(),
capa: st.capaTmp || {tipo:'padrao', chave:'aurora'}
};
if(!o.prazo) return erro('Defina o prazo — sem data-limite o objetivo não tem ritmo.');
const alvo = st.edit ? okrObj(st.edit) : null;
if(alvo){
const antes = alvo.titulo;
Object.assign(alvo, o, {alteradoEm:agoraISO()});
okrLog(alvo, antes!==o.titulo ? 'renomeou o objetivo (antes: “'+antes+'”)' : 'editou os dados do objetivo');
} else {
const novo = Object.assign({id:admId(), krs:(st.preKrs||[]), comentarios:[], anexos:[], historico:[],
origem:st.origem||'', criadoPor:eu().nome, criadoEm:agoraISO()}, o);
okrLog(novo,'criou o objetivo');
okrObjetivos().push(novo);
st.aberto = novo.id;
}
st.preKrs = null; st.origem = null;
salvar(); fechar('ov-okr-edit');
if($('ov-okr').classList.contains('on')) okrDesenhar();
else if(!alvo) okrAbrir(st.aberto, 'krs');
renderAdmin();
toast(alvo ? 'Objetivo atualizado.' : 'Objetivo criado. Agora defina os resultados-chave que dizem se chegamos lá.');
}
async function okrApagarObjetivo(){
if(!okrPodeEditar()){ toast('Só a Administração exclui objetivos.'); return; }
const o = okrObj(okrEstado().aberto); if(!o) return;
if(!await confirmar('Excluir o objetivo “'+o.titulo+'”?\n\n'+
(o.krs||[]).length+' resultado(s)-chave, '+okrComentarios(o).length+' mensagem(ns) e todo o histórico vão junto.'+
'\n\nEssa exclusão não pode ser desfeita.')) return;
S.metas.objetivos = okrObjetivos().filter(x=>String(x.id)!==String(o.id));
salvar(); fechar('ov-okr'); renderAdmin(); toast('Objetivo excluído.');
}
function okrExcluirDoForm(){ fechar('ov-okr-edit'); setTimeout(okrApagarObjetivo, 120); }
/* Trocar a capa sem abrir o formulário inteiro. */
function okrTrocarCapa(){
const o = okrObj(okrEstado().aberto); if(!o) return;
okrFormObjetivo(o.id);
setTimeout(()=>{ const el=document.querySelector('.okr-capaesc'); if(el) el.scrollIntoView({block:'start'}); }, 120);
}
/* ---------- etiquetas: um campo de fichas que aceita texto novo ---------- */
function okrTagsHTML(){
return '
'+
''+
''+
''+
'
';
}
function okrTagsPintar(usadas){
const st = okrEstado(), sel = st.tagsTmp||[];
const chips = $('oke-tagchips');
if(chips) chips.innerHTML = sel.length
? sel.map(t=>{ const c=okrCorEtiqueta(t);
return ''+esc(t)+
''; }).join('')
: 'Nenhuma etiqueta ainda.';
const sug = $('oke-tagsug');
if(sug){
const livres = (usadas || [...new Set(OKR_ETIQUETAS.concat(
okrObjetivos().reduce((a,x)=>a.concat(x.etiquetas||[]),[])))]).filter(t=>sel.indexOf(t)<0);
sug.innerHTML = livres.length
? 'sugestões:'+livres.slice(0,14).map(t=>
'').join('')
: '';
}
}
function okrTagPor(t){
const st = okrEstado(); st.tagsTmp = st.tagsTmp||[];
t = String(t||'').trim(); if(!t) return;
if(st.tagsTmp.indexOf(t)<0) st.tagsTmp.push(t);
okrTagsPintar(); okrCoach();
}
function okrTagTirar(t){
const st = okrEstado();
st.tagsTmp = (st.tagsTmp||[]).filter(x=>x!==t);
okrTagsPintar(); okrCoach();
}
function okrTagTecla(e){
if(e.key==='Enter'){ e.preventDefault(); const v=e.target.value.trim(); if(v){ okrTagPor(v); e.target.value=''; } }
else if(e.key==='Backspace' && !e.target.value){ const st=okrEstado();
if((st.tagsTmp||[]).length){ st.tagsTmp.pop(); okrTagsPintar(); okrCoach(); } }
}
/* ---------- KPI manual: cadastro e leitura ---------- */
function okrKpiForm(id){
if(!okrPodeEditar()) return;
const k = id ? okrKpis().find(x=>String(x.id)===String(id)) : null;
okrEstado().kpiEdit = id || null;
const auto = k && k.auto ? okrKpiAuto(k.auto) : null;
$('okk-titulo').textContent = k ? (k.nome||'Indicador') : 'Novo indicador';
$('okk-corpo').innerHTML =
(auto ? '
Este indicador é automático: a plataforma calcula o valor '+
'sozinha ('+esc(auto.ajuda)+'). Aqui você só ajusta o nome exibido e a meta.
' : '')+
'
'+
campo('full','Nome do indicador',true,'')+
(auto ? '' :
campo('','Unidade',false,'')+
campo('','Direção',false,''))+
campo('','Meta',false,'',null,
'Com a meta preenchida, o cartão fica verde quando o indicador está do lado bom dela.')+
(auto ? '' : campo('','Valor atual',false,''))+
campo('full','Ligar a um objetivo',false,'',null,
'Opcional. Serve para lembrar qual objetivo depende deste sinal.')+
(auto ? '' : campo('full','O que este número quer dizer',false,''))+
'
'+
'';
abrir('ov-okr-kpi');
setTimeout(()=>{ const el=$('okk-nome'); if(el) el.focus(); }, 60);
}
function okrSalvarKpi(){
if(!okrPodeEditar()) return;
const st = okrEstado();
const num = v => { const n=parseFloat(String(v).replace(/\./g,'').replace(',','.')); return isNaN(n)?null:n; };
const nome = ($('okk-nome').value||'').trim();
if(!nome){ const b=$('okk-err'); b.textContent='Dê um nome ao indicador.'; b.classList.add('on'); return; }
const alvo = st.kpiEdit ? okrKpis().find(x=>String(x.id)===String(st.kpiEdit)) : null;
const o = {nome:nome, meta:num($('okk-meta').value), objetivoId:$('okk-obj').value||''};
if($('okk-un')) o.unidade = $('okk-un').value;
if($('okk-dir')) o.dir = $('okk-dir').value;
if($('okk-ajuda')) o.ajuda = ($('okk-ajuda').value||'').trim();
if(alvo){
Object.assign(alvo, o);
if($('okk-atual')){
const v = num($('okk-atual').value);
if(v!=null){ alvo.hist = alvo.hist||[]; const d=iso(hoje0());
const ja=alvo.hist.find(h=>h.data===d); if(ja) ja.valor=v; else alvo.hist.push({data:d, valor:v}); }
}
} else {
const novo = Object.assign({id:admId(), hist:[], criadoEm:agoraISO()}, o);
const v = $('okk-atual') ? num($('okk-atual').value) : null;
if(v!=null) novo.hist.push({data:iso(hoje0()), valor:v});
okrKpis().push(novo);
}
salvar(); fechar('ov-okr-kpi'); okrRender();
toast(alvo?'Indicador atualizado.':'Indicador criado.');
}
async function okrKpiLeitura(id){
if(!okrPodeEditar()) return;
const k = okrKpis().find(x=>String(x.id)===String(id)); if(!k) return;
const v = await okrPerguntar('Nova leitura de “'+(k.nome||'')+'”', '');
if(v===null) return;
const n = parseFloat(String(v).replace(/\./g,'').replace(',','.'));
if(isNaN(n)){ toast('Informe um número.'); return; }
k.hist = k.hist||[];
const d = iso(hoje0());
const ja = k.hist.find(h=>h.data===d);
if(ja) ja.valor = n; else k.hist.push({data:d, valor:n});
if(k.hist.length>60) k.hist = k.hist.slice(-60);
salvar(); okrRender(); toast('Leitura registrada.');
}
/* ---------------- tela ---------------- */
function montarAdmin(){
const el = $('pane-admin'); if(!el) return;
if(!admPode()){ el.innerHTML='
Menu restrito
O Administrativo é da Administração e dos advogados sócios.
'; return; }
const st = admEstado();
el.innerHTML =
'
'+
'
'+ ADM_BLOCOS.map(b=>
'').join('')+
'
'+
''+
''+
''+
'
'+
'
'+
'
'+
''+
''+
''+
'
'+
(admTemGerador(st.bloco) ? '' : '')+
'
';
if(admTemGerador(st.bloco)){ $('adm-gerador').innerHTML = gsFormHTML('gsa'); gsSaida('gsa'); }
renderAdmin();
}
/* O gerador de senhas acompanha os blocos em que ele faz sentido — acessos,
documentos e o dossiê da equipe. Nas Metas e nos Treinamentos ele só ocupava
espaço, e a tela pede a largura inteira. */
function admTemGerador(k){ return k!=='metas' && k!=='treinos'; }
function admRotuloNovo(){
const k = admEstado().bloco;
return k==='senhas'?'Novo sistema' : k==='docs'?'Novo documento'
: k==='pessoas'?'Novo colaborador' : k==='metas'?'Novo objetivo' : 'Novo treinamento';
}
function admBloco(k){ admEstado().bloco=k; montarAdmin(); }
function renderAdmin(){
if(!$('adm-corpo')) return;
const st = admEstado(), k = st.bloco, f = st.f[k];
/* Gestão de Pessoas e Metas têm tela própria: nada de cartão, filtro ou ordem dos outros blocos */
if(k==='pessoas' || k==='metas'){
const topo0 = document.querySelector('#pane-admin .admtopo');
if(topo0) topo0.classList.add('largo');
if(k==='metas') okrRender(); else gpRender();
return;
}
const todos = admLista(k);
$('adm-nota').innerHTML = ({
senhas:'As senhas ficam guardadas na base do escritório, visíveis apenas para a Administração e os sócios. '+
'Cada registro tem um botão para revelar e copiar, e três níveis de visibilidade: toda a equipe, que abre o '+
'acesso no menu Acesso rápido; pessoas escolhidas, que aparece na lista de todos mas só abre para quem foi '+
'liberado; e somente para a Administração, que nem os advogados sócios enxergam — para eles fica apenas o '+
'nome, com cadeado. Sem nenhum dos três, o registro é da gestão: Administração e sócios.',
docs:'O arquivo da sociedade: contrato social, alvarás, LTCAT, contratos com prestadores, certidões. Cada documento aceita um arquivo anexado ou o link da pasta no Drive.',
treinos:'O registro do que a equipe aprendeu: quem participou, o que foi tratado, o material e o aproveitamento.'
})[k];
/* filtros de cada bloco */
const sel = (id, rot, opcoes, valor) => '';
let filtros = 'Filtrar'+
'';
if(k==='senhas'){
filtros += sel('cat','Categoria', ADM_CATEGORIAS, f.cat) +
sel('comp','Visibilidade', ['Com toda a equipe','Com pessoas escolhidas','Só a Administração','Só da gestão'], f.comp);
} else if(k==='docs'){
const usadas = [...new Set(admDocs().map(d=>d.etiqueta).filter(Boolean))];
filtros += sel('et','Etiqueta', [...new Set(ADM_ETIQUETAS.concat(usadas))], f.et) +
sel('comp','Visibilidade', ['Com toda a equipe','Com pessoas escolhidas','Só a Administração','Só da gestão'], f.comp);
} else {
const anos = [...new Set(admTreinos().map(t=>String(t.data||'').slice(0,4)).filter(Boolean))].sort().reverse();
filtros += sel('area','Área', ADM_AREAS, f.area) +
sel('quem','Participante', ativos().map(m=>m.nome), f.quem) +
sel('ano','Ano', anos, f.ano);
}
/* como ordenar — e, nos documentos, como visualizar */
const temManual = todos.some(x=>x.ordem!=null);
filtros += 'Ordenar'+
'';
if(k==='docs'){
filtros += ''+
''+
''+
'';
}
const lista = admFiltrar(k);
filtros += ''+lista.length+' de '+todos.length+' registro(s)';
if(ehAdmin() && temManual && !st.ord[k])
filtros += '';
$('adm-filtros').innerHTML = filtros;
admRenderCorpo();
}
/* ---------------------------------------------------------------
SÓ A LISTA
A barra de filtros contém o próprio campo de busca. Redesenhá-la a
cada tecla arrancava o campo do meio da digitação e o foco ia junto —
só dava para escrever uma letra por vez. A busca agora refaz apenas a
lista e o contador, e o campo fica onde está, com o cursor no lugar.
--------------------------------------------------------------- */
function admRenderCorpo(){
if(!$('adm-corpo')) return;
const st = admEstado(), k = st.bloco;
const todos = admLista(k), lista = admFiltrar(k);
const kanban = (k==='docs' && st.vista.docs==='kanban');
/* o quadro precisa da largura inteira: o gerador de senhas desce para baixo */
const topo = document.querySelector('#pane-admin .admtopo');
if(topo) topo.classList.toggle('largo', kanban || !admTemGerador(k));
const cnt = document.querySelector('#adm-filtros .cruzres');
if(cnt) cnt.textContent = lista.length+' de '+todos.length+' registro(s)';
$('adm-corpo').innerHTML = !lista.length
? '
'+(todos.length?'Nada com esse recorte':'Bloco vazio')+'
'+
(todos.length?'Ajuste os filtros acima.':esc(ADM_BLOCOS.find(b=>b.k===k).vazio))+'
'
: kanban ? admKanban(lista)
: '
'+lista.map(x=>admCard(k,x)).join('')+'
';
if(admPodeArrastar() && lista.length && !kanban) admLigarArrasto();
}
function admOrdem(v){ const st=admEstado(); st.ord[st.bloco]=v; renderAdmin(); }
function admVista(v){ admEstado().vista.docs=v; renderAdmin(); }
/* ---------- quadro por etiqueta (Documentos) ----------
Uma coluna para cada etiqueta em uso, na ordem da lista de Configurações,
com a mesma cor que a etiqueta tem no cartão. */
function admKanban(lista){
const usadas = [];
lista.forEach(d=>{ const e = d.etiqueta || 'Sem etiqueta';
if(usadas.indexOf(e)<0) usadas.push(e); });
const ordem = ADM_ETIQUETAS.slice();
usadas.sort((a,b)=>{
const ia = ordem.indexOf(a), ib = ordem.indexOf(b);
if(a==='Sem etiqueta') return 1;
if(b==='Sem etiqueta') return -1;
if(ia>=0 && ib>=0) return ia-ib;
if(ia>=0) return -1;
if(ib>=0) return 1;
return a.localeCompare(b,'pt-BR');
});
return '
'+total+' documento(s)'+
(compartilhados?' · '+compartilhados+' com a equipe':'')+
(restritos?' · '+restritos+' restrito(s)':'')+'
'+
'
'+dela.map(x=>admCard('docs',x)).join('')+'
'+
'
';
}).join('')+'
';
}
function admFiltro(campo, valor){
const st=admEstado(); st.f[st.bloco][campo]=valor; renderAdmin();
}
/* A busca refaz só a lista — o campo em que se está digitando não é tocado. */
function admBuscar(valor){
const st=admEstado(); st.f[st.bloco].q = valor;
if(st.bloco==='pessoas') gpRenderCorpo(); else admRenderCorpo();
}
function admFiltroComp(v){
const alvo = v==='Com toda a equipe' ? 'equipe'
: v==='Com pessoas escolhidas' ? 'pessoas'
: v==='Só a Administração' ? 'admin' : 'nenhum';
return x => admSituacao(x).chave === alvo;
}
function admFiltrar(k){
const st=admEstado(), f=st.f[k], q=iaNorm(f.q||'');
let lista = admLista(k).slice();
/* no cofre da Administração, o sócio só busca pelo nome — o resto é conteúdo */
if(q) lista = lista.filter(x=>{
const campos = admVejoEsse(x)
? [x.sistema,x.titulo,x.login,x.url,x.obs,x.etiqueta,x.categoria,x.area,x.resumo]
: [x.sistema,x.titulo];
return iaNorm(campos.filter(Boolean).join(' ')).indexOf(q)>=0;
});
if(k==='senhas'){
if(f.cat) lista=lista.filter(x=>x.categoria===f.cat);
if(f.comp) lista=lista.filter(admFiltroComp(f.comp));
} else if(k==='docs'){
if(f.et) lista=lista.filter(x=>x.etiqueta===f.et);
if(f.comp) lista=lista.filter(admFiltroComp(f.comp));
} else {
if(f.area) lista=lista.filter(x=>x.area===f.area);
if(f.ano) lista=lista.filter(x=>String(x.data||'').slice(0,4)===f.ano);
if(f.quem){ const m=S.membros.find(y=>y.nome===f.quem);
if(m) lista=lista.filter(x=>(x.participantes||[]).indexOf(m.id)>=0); }
}
return admOrdenar(k, lista);
}
/* A Administração pode arrastar os cartões para a ordem que fizer sentido no
dia a dia. Enquanto ninguém arrastar, vale a ordem natural: alfabética nos
sistemas e documentos, do mais recente para o mais antigo nos treinamentos.
Um registro criado depois de uma arrumação entra no fim da fila. */
function admNome(x){ return String(x.sistema||x.titulo||''); }
function admOrdenar(k, lista){
const alfa = (a,b)=> admNome(a).localeCompare(admNome(b),'pt-BR',{sensitivity:'base'});
const criado = (a,b)=> String(a.criadoEm||'').localeCompare(String(b.criadoEm||''));
const natural = (a,b)=> k==='treinos'
? String(b.data||'').localeCompare(String(a.data||''))
: alfa(a,b);
const escolha = admEstado().ord[k] || '';
if(escolha==='az') return lista.sort(alfa);
if(escolha==='za') return lista.sort((a,b)=>alfa(b,a));
if(escolha==='novos') return lista.sort((a,b)=>criado(b,a));
if(escolha==='antigos') return lista.sort(criado);
/* automática: respeita o que a Administração arrastou; sem arrasto, a ordem
natural do bloco — alfabética nos sistemas e documentos, da mais recente
para a mais antiga nos treinamentos */
const manual = lista.some(x=>x.ordem!=null);
if(!manual) return lista.sort(natural);
return lista.sort((a,b)=>{
const oa = a.ordem==null ? Infinity : a.ordem, ob = b.ordem==null ? Infinity : b.ordem;
if(oa !== ob) return oa - ob;
return natural(a,b);
});
}
/* Arrastar só faz sentido na ordem automática e na visão em cartões: com uma
ordenação escolhida, ela é que manda. */
function admPodeArrastar(){
const st = admEstado();
if(st.bloco==='docs' && st.vista.docs==='kanban') return false;
return ehAdmin() && !st.ord[st.bloco];
}
/* Grava a ordem que ficou na tela, para todo mundo ver igual. */
function admGravarOrdem(k, ids){
if(!admPodeArrastar()) return;
const lista = admLista(k);
ids.forEach((id,i)=>{ const x = lista.find(y=>String(y.id)===String(id)); if(x) x.ordem = i; });
/* o que não estava na tela (filtrado) vai para depois, preservando o que havia */
let n = ids.length;
lista.forEach(x=>{ if(ids.indexOf(String(x.id))<0 && x.ordem==null) x.ordem = n++; });
salvar();
}
function admMover(k, id, passo, ev){
if(ev) ev.stopPropagation();
if(!admPodeArrastar()) return;
const ids = admFiltrar(k).map(x=>String(x.id));
const i = ids.indexOf(String(id)); if(i<0) return;
const j = i + passo; if(j<0 || j>=ids.length) return;
ids.splice(j, 0, ids.splice(i,1)[0]);
admGravarOrdem(k, ids);
renderAdmin();
}
async function admOrdemNatural(){
if(!ehAdmin()) return;
const k = admEstado().bloco;
if(!await confirmar('Esquecer a ordem que você arrastou neste bloco?\n\nEle volta à ordem automática.')) return;
admLista(k).forEach(x=>{ delete x.ordem; });
salvar(); renderAdmin();
toast('Ordem automática restaurada.');
}
/* ---------- arrastar os cartões (somente Administração) ----------
Mesma mecânica do Acesso rápido: eventos de ponteiro, para funcionar igual
no computador, no tablet e no celular. */
var ADM_DRAG = null;
function admLigarArrasto(){
const g = document.querySelector('#adm-corpo .admgrid');
if(!g || g._dnd) return;
g._dnd = true;
g.addEventListener('pointerdown', e=>{
if(!admPodeArrastar()) return;
const alca = e.target.closest && e.target.closest('.adm-gr');
if(!alca) return;
const card = alca.closest('.admcard[data-adm]'); if(!card) return;
e.preventDefault();
ADM_DRAG = {card:card, moveu:false, bloco:g.dataset.bloco};
card.classList.add('arrastando');
try{ alca.setPointerCapture(e.pointerId); }catch(x){}
document.body.classList.add('aq-arrastando');
});
g.addEventListener('pointermove', e=>{
if(!ADM_DRAG) return;
e.preventDefault(); ADM_DRAG.moveu = true;
const alvo = admAlvoDepois(g, e.clientX, e.clientY), card = ADM_DRAG.card;
/* já está imediatamente antes do alvo? então não há o que mover */
if(alvo === null){ if(g.lastElementChild !== card) g.appendChild(card); }
else if(alvo !== card && card.nextSibling !== alvo) g.insertBefore(card, alvo);
});
const soltar = ()=>{
if(!ADM_DRAG) return;
const card = ADM_DRAG.card, moveu = ADM_DRAG.moveu, bloco = ADM_DRAG.bloco;
ADM_DRAG = null;
card.classList.remove('arrastando');
document.body.classList.remove('aq-arrastando');
if(!moveu) return;
admGravarOrdem(bloco, [...g.querySelectorAll('.admcard[data-adm]')].map(x=>x.dataset.adm));
toast('Nova ordem salva para toda a equipe.');
renderAdmin();
};
g.addEventListener('pointerup', soltar);
g.addEventListener('pointercancel', soltar);
}
function admAlvoDepois(grid, x, y){
const outros = [...grid.querySelectorAll('.admcard[data-adm]')]
.filter(el=>el !== (ADM_DRAG && ADM_DRAG.card));
let melhor = null, menor = Infinity;
outros.forEach(el=>{
const r = el.getBoundingClientRect();
const cx = r.left + r.width/2, cy = r.top + r.height/2;
const d = Math.hypot(x - cx, (y - cy) * 1.6);
if(d < menor){ menor = d; melhor = {el:el, antes:(x < cx || y < r.top)}; }
});
if(!melhor) return null;
return melhor.antes ? melhor.el : melhor.el.nextElementSibling;
}
/* ---------------- cores das etiquetas ----------------
Cada etiqueta de documento e cada área de treinamento ganha uma cor fixa,
para reconhecer o assunto de relance. A cor sai do próprio texto, então
uma etiqueta nova criada em Configurações › Listas já nasce colorida e
continua com a mesma cor em todas as telas. */
const ADM_PALETA = [
{c:['#e3edfb','#1a4f9c'], e:['#16263c','#9fc2f4']},
{c:['#e7f4ea','#1c6b3f'], e:['#13291f','#7fd3a6']},
{c:['#fdeeea','#a83a25'], e:['#2c1a19','#f2a294']},
{c:['#f3ecfb','#5b3391'], e:['#241c33','#c9aef0']},
{c:['#fdf3e0','#8a5a00'], e:['#2a2216','#e9b872']},
{c:['#e6f4f4','#0f6470'], e:['#132628','#83cdd6']},
{c:['#fbe9f2','#96276a'], e:['#2b1723','#efa2ca']},
{c:['#eef0e5','#5a6a25'], e:['#212516','#c3d383']},
{c:['#eae9f7','#3b3d94'], e:['#1c1d31','#a9abec']},
{c:['#fbecdf','#9c5312'], e:['#2a1e14','#eaa66c']},
{c:['#e8f2ef','#25705f'], e:['#152622','#87cdbc']},
{c:['#f5eaea','#8a3a4a'], e:['#291a1d','#e8a3ad']},
{c:['#e9eef5','#3c5470'], e:['#1a2029','#a8bdd6']},
{c:['#f7edd9','#7a5b12'], e:['#282013','#dcbb74']},
{c:['#ecf1e8','#3f6b33'], e:['#182014','#a6cf99']},
{c:['#f2eaf3','#6d3170'], e:['#231a26','#d4a8d8']}
];
/* A cor sai da posição da etiqueta na lista de Configurações: assim duas
etiquetas vizinhas nunca caem na mesma cor. O que não estiver na lista
recebe uma cor estável derivada do próprio texto. */
function admCorDe(txt, lista){
const s = String(txt||'').trim();
if(!s) return null;
let i = -1;
if(Array.isArray(lista)) i = lista.indexOf(s);
if(i < 0){
const b = s.toLowerCase();
let h = 0;
for(let j=0;j>> 0; }
i = h;
}
const par = ADM_PALETA[i % ADM_PALETA.length];
return document.body.classList.contains('dark') ? par.e : par.c;
}
function admEtiqueta(txt, titulo, lista){
if(!txt) return '';
const c = admCorDe(txt, lista);
return ''+esc(txt)+'';
}
/* O que o advogado sócio vê quando o registro é do cofre da Administração:
existe, tem nome, e nada mais. */
function admCardTrancado(k, x){
const rot = {senhas:'Sistema', docs:'Documento', treinos:'Treinamento'}[k]||'Registro';
return '
'+
'
'+esc(x.sistema||x.titulo||'—')+
'🔒 só a Administração
'+
'
🔒'+esc(rot)+' reservado à Administração. '+
'O conteúdo não aparece para os advogados sócios.
'+
'
';
}
function admCard(k, x){
if(!admVejoEsse(x)) return admCardTrancado(k, x);
const sit = admSituacao(x);
const nomes = admNomesLiberados(x);
const compart =
sit.chave==='admin'
? '🛡 só a Administração'
: sit.chave==='equipe'
? '✓ toda a equipe'
: sit.chave==='pessoas'
? ''
+'🔒 '+admEscolhidos(x).length+' pessoa(s)'
: '';
const link = String(x.url||'').trim();
const abrir = link ? '↗ Abrir' : '';
const baixar = x.arquivoNome
? '' : '';
let miolo='';
if(k==='senhas'){
const ver = !!admEstado().verSenha[x.id];
miolo = '
'+admEtiqueta(x.area,'Área abordada no treinamento',ADM_AREAS)+
(x.aproveitamento?''+esc(x.aproveitamento)+'':'')+
(x.data?''+fmtD(x.data)+'':'')+'
'+
(nomes.length?'
Participantes'+esc(nomes.join(', '))+'
':'')+
(x.resumo?'
'+esc(x.resumo)+'
':'');
}
/* a alça e as setas de ordem só existem para a Administração */
const mover = admPodeArrastar()
? '
'+
''+
'⠿'+
''+
'
'
: '';
/* Editar e Excluir ficam sempre à vista, na barra de ações do cartão: a pena
que só aparecia ao passar o mouse não se achava no toque nem de relance. */
const editar = '';
const excluir = admPodeExcluir()
? '' : '';
return '
'+
mover+
'
'+esc(x.sistema||x.titulo||'—')+compart+'
'+
miolo+
(x.obs?'
'+esc(x.obs)+'
':'')+
'
'+abrir+baixar+
''+
''+
/* definir o cofre é privativo da Administração */
(ehAdmin()
? ''
: '')+
'
'+
(nomes.length ? '
🔒 Aberto para '+esc(nomes.join(', '))+''+
(x.compartilhado?' — e para todos, porque também está compartilhado com a equipe.':'')+'
';
}
/* Copia sem embutir o valor no HTML: uma senha com aspas quebraria o atalho. */
function admCopiar(id, campo, ev){
if(ev) ev.stopPropagation();
const x = admSenhas().find(y=>y.id===id); if(!x) return;
if(!admVejoEsse(x)){ toast('Registro reservado à Administração.'); return; }
copiar(x[campo]||'', campo==='senha'?'Senha':'Usuário', ev, true);
toast((campo==='senha'?'Senha':'Usuário')+' de '+(x.sistema||'')+' copiado.');
}
function admVer(id, ev){
if(ev) ev.stopPropagation();
const x = admSenhas().find(y=>y.id===id);
if(x && !admVejoEsse(x)){ toast('Registro reservado à Administração.'); return; }
const v=admEstado().verSenha; v[id]=!v[id]; renderAdmin();
}
function admBaixar(k, id, ev){
if(ev) ev.stopPropagation();
const x = admLista(k).find(y=>y.id===id); if(!x || !x.arquivoDados) return;
/* o cartão trancado já esconde o botão; aqui a porta se fecha de verdade */
if(!admPode() || !admVejoEsse(x)){ toast('Este anexo é reservado à Administração.'); return; }
if(!admVejoEsse(x)){ toast('Registro reservado à Administração.'); return; }
baixarDataUrl(x.arquivoNome||'arquivo', x.arquivoDados);
}
/* As três visibilidades são excludentes: escolher uma desfaz as outras. */
async function admTrancar(k, id){
if(!ehAdmin()){ toast('Só a Administração define o que fica reservado a ela.'); return; }
const x = admLista(k).find(y=>String(y.id)===String(id)); if(!x) return;
const nome = x.sistema || x.titulo || 'este registro';
if(!x.soAdm){
const tinha = x.compartilhado || admEscolhidos(x).length;
if(!await confirmar('Deixar "'+nome+'" visível somente para a Administração?'+
(tinha ? '\n\nEle sai do Acesso rápido e os advogados sócios passam a ver apenas o nome, com cadeado.'
: '\n\nOs advogados sócios passam a ver apenas o nome, com cadeado.'))) return;
x.soAdm = true; x.compartilhado = false; x.compartilhadoCom = [];
} else {
x.soAdm = false;
}
x.alteradoEm = agoraISO();
salvar(); renderAdmin();
toast(x.soAdm ? 'Reservado à Administração. Os sócios veem só o nome, com cadeado.'
: 'Liberado de volta para a gestão — Administração e advogados sócios.');
}
async function admCompartilhar(k, id){
if(!admPode()) return;
const x = admLista(k).find(y=>y.id===id); if(!x) return;
if(!admVejoEsse(x)){ toast('Registro reservado à Administração.'); return; }
if(!x.compartilhado && x.soAdm){
if(!await confirmar('"'+(x.sistema||x.titulo)+'" está reservado à Administração.\n\nCompartilhar com toda a equipe tira essa reserva. Continuar?')) return;
x.soAdm = false;
}
if(!x.compartilhado && k==='senhas'){
if(!await confirmar('Compartilhar "'+(x.sistema||x.titulo)+'" com toda a equipe?\n\nA senha passa a ficar visível no menu Acesso rápido para todos os integrantes, inclusive estagiários e associados.')) return;
}
x.compartilhado = !x.compartilhado;
x.alteradoEm = agoraISO();
const n = admEscolhidos(x).length;
salvar(); renderAdmin();
toast(x.compartilhado
? 'Publicado no Acesso rápido, em “Acessos e Arquivos Relevantes”.'
: (n ? 'Não é mais de toda a equipe. Segue liberado para '+n+' pessoa(s), e bloqueado para os demais.'
: 'Retirado do Acesso rápido — voltou a ser só da gestão.'));
}
/* ---------------- compartilhar com pessoas escolhidas ---------------- */
var ADM_SHARE = null;
function admShareAbrir(k, id){
if(!admPode()){ toast('Menu restrito à Administração e aos sócios.'); return; }
const x = admLista(k).find(y=>String(y.id)===String(id)); if(!x) return;
if(!admVejoEsse(x)){ toast('Registro reservado à Administração.'); return; }
ADM_SHARE = {bloco:k, id:x.id};
const nome = x.sistema || x.titulo || 'este registro';
const eusou = eu();
const gente = atribuiveis().filter(m=>!eusou || m.id!==eusou.id);
$('ash-titulo').textContent = nome;
$('ash-corpo').innerHTML =
'
Escolha quem pode abrir este registro. Ele passa a aparecer no '+
'Acesso rápido de toda a equipe, mas fechado a cadeado para quem não estiver na lista — assim '+
'todos sabem que o acesso existe e a quem pedir, sem enxergar o conteúdo.'+
(x.compartilhado ? '
Atenção: este registro está compartilhado com toda a equipe. Enquanto '+
'estiver assim, todos veem o conteúdo, independentemente desta lista.' : '')+
'
'+
'
'+
campo('full','Pessoas com acesso liberado',false, msHTML('ash-quem','Digite o nome do integrante…'), null,
'Aceita mais de uma pessoa. Administração e advogados sócios já enxergam tudo pelo menu Administrativo.')+
'
'+
'';
$('ash-rod').textContent = gente.length ? '' : 'Nenhum outro integrante ativo cadastrado.';
msInit('ash-quem', gente.map(m=>({id:m.id, nome:m.nome, sub:m.funcao, busca:m.funcao})), admEscolhidos(x));
abrir('ov-admshare');
}
function admShareSalvar(){
if(!ADM_SHARE) return;
if(!admPode()){ toast('Menu restrito à Administração e aos sócios.'); return; }
const x = admLista(ADM_SHARE.bloco).find(y=>String(y.id)===String(ADM_SHARE.id));
if(!x){ fechar('ov-admshare'); return; }
const antes = admEscolhidos(x).length;
x.compartilhadoCom = msSel('ash-quem').slice();
x.alteradoEm = agoraISO();
const n = x.compartilhadoCom.length;
salvar(); fechar('ov-admshare'); renderAdmin();
toast(n ? 'Liberado para '+n+' pessoa(s). Para os demais, aparece bloqueado no Acesso rápido.'
: (antes ? 'Liberação retirada — voltou a ser só da gestão.' : 'Nenhuma pessoa escolhida.'));
ADM_SHARE = null;
}
/* ---------------- formulário ---------------- */
function admNovo(){
const k = admEstado().bloco;
if(k==='pessoas') return gpNovaPessoa();
if(k==='metas') return okrNovoObjetivo();
admForm(k, null);
}
function admEditar(k, id){ admForm(k, admLista(k).find(x=>x.id===id)||null); }
function admForm(k, x){
if(!admPode()){ toast('Menu restrito à Administração e aos sócios.'); return; }
if(x && !admVejoEsse(x)){ toast('Este registro é reservado à Administração.'); return; }
const st = admEstado();
st.edit = {bloco:k, id:x?x.id:null};
st.arqTmp = x && x.arquivoDados ? {nome:x.arquivoNome, dados:x.arquivoDados} : null;
const v = c => esc(x && x[c] ? x[c] : '');
const opc = (lista, atual) => ''+
lista.map(o=>'').join('')+
(atual && lista.indexOf(atual)<0 ? '' : '');
let corpo='';
if(k==='senhas'){
$('adm-kind').textContent='Administrativo · senhas de sistemas';
$('adm-titulo').textContent = x ? (x.sistema||'Sistema') : 'Novo sistema';
corpo = '
'+
campo('full','Nome do sistema',true,'','adm-sistema')+
campo('','Categoria',false,'')+
campo('','Usuário ou login',false,'')+
campo('','Senha',true,'
'+
'
','adm-senha')+
campo('full','Link da plataforma',false,'')+
campo('full','Arquivo anexado',false,
'
'+
'
',null,
'Até 3 MB. É aqui que ficam os códigos de recuperação da verificação em duas etapas — '+
'deixe o registro como só a Administração quando anexar um.')+
campo('full','Observações',false,'')+
'
'+
campo('full','Título do documento',true,'','adm-titulo-doc')+
campo('','Etiqueta',true,'','adm-etiqueta')+
campo('','Válido até',false,'',null,
'Opcional — útil para alvará, certidão e LTCAT.')+
campo('full','Arquivo',false,
'
'+
'
',null,
'Até 3 MB. Para arquivos maiores, use o link do Drive abaixo.')+
campo('full','Link do Drive',false,'')+
campo('full','Observações',false,'')+
'
';
} else {
$('adm-kind').textContent='Administrativo · treinamentos';
$('adm-titulo').textContent = x ? (x.titulo||'Treinamento') : 'Novo treinamento';
corpo = '
'+
campo('full','Título do treinamento',true,'','adm-titulo-tre')+
campo('','Data',true,'','adm-data')+
campo('','Área abordada',true,'','adm-area')+
campo('','Aproveitamento',false,'',null,
'Como o escritório avalia o resultado do treinamento.')+
campo('full','Participantes',false, msHTML('adm-part','Digite o nome do integrante…'), null,
'Quem participou. Aceita mais de um.')+
campo('full','Resumo do treinamento',false,'')+
campo('full','Material anexado',false,
'
'+
'
',null,'Até 3 MB.')+
campo('full','Link do material',false,'')+
campo('full','Observações',false,'')+
'
';
}
$('adm-corpo-modal').innerHTML = corpo +
'
'+
(x && x.compartilhado
? 'Este registro está compartilhado com toda a equipe e aparece no menu Acesso rápido.'
: (x && admEscolhidos(x).length
? 'Este registro está liberado para '+esc(admNomesLiberados(x).join(', '))+'. No Acesso rápido dos '+
'demais integrantes ele aparece bloqueado.'
: 'Depois de salvar, os botões do cartão publicam o registro no Acesso rápido: com toda a equipe '+
'ou com pessoas escolhidas — neste caso, ele aparece bloqueado para quem não estiver na lista.'))+
'
'+
'';
if(k==='treinos'){
msInit('adm-part', atribuiveis().map(m=>({id:m.id, nome:m.nome, sub:m.funcao, busca:m.funcao})), (x&&x.participantes)||[]);
}
admNomeArquivo();
/* excluir é privativo da Administração; o sócio edita e salva normalmente */
$('adm-excluir').style.display = (x && admPodeExcluir()) ? '' : 'none';
abrir('ov-adm');
}
function admNomeArquivo(){
const el=$('adm-arq-nome'); if(!el) return;
const a=admEstado().arqTmp;
el.innerHTML = a ? '📎 '+esc(a.nome)+' '
: 'Nenhum arquivo anexado.';
}
function admTirarArquivo(){ admEstado().arqTmp=null; admNomeArquivo(); }
function admArquivo(ev){
const f = ev.target.files && ev.target.files[0]; if(!f) return;
if(f.size > ADM_MAX_ARQ){ toast('Arquivo acima de 3 MB. Use o link do Drive para arquivos grandes.'); ev.target.value=''; return; }
const r = new FileReader();
r.onload = () => { admEstado().arqTmp = {nome:f.name, dados:r.result}; admNomeArquivo(); };
r.readAsDataURL(f);
ev.target.value='';
}
function admSalvar(){
const st = admEstado(), k = st.edit.bloco;
const val = id => $(id) ? ($(id).value||'').trim() : '';
const erro = msg => { const b=$('adm-err'); b.textContent=msg; b.classList.add('on'); };
let o = {};
if(k==='senhas'){
o = {sistema:val('adm-sistema'), categoria:val('adm-categoria'), login:val('adm-login'),
senha:val('adm-senha'), url:val('adm-url'), obs:val('adm-obs')};
if(!o.sistema) return erro('Informe o nome do sistema.');
if(!o.senha) return erro('Informe a senha — é o motivo de o registro existir.');
} else if(k==='docs'){
o = {titulo:val('adm-titulo-doc'), etiqueta:val('adm-etiqueta'), validade:val('adm-validade'),
url:val('adm-url'), obs:val('adm-obs')};
if(!o.titulo) return erro('Informe o título do documento.');
if(!o.etiqueta) return erro('Escolha uma etiqueta — é ela que organiza o arquivo.');
if(!o.url && !st.arqTmp) return erro('Anexe o arquivo ou informe o link do Drive.');
} else {
o = {titulo:val('adm-titulo-tre'), data:val('adm-data'), area:val('adm-area'),
aproveitamento:val('adm-aprov'), resumo:val('adm-resumo'), url:val('adm-url'),
obs:val('adm-obs'), participantes:msSel('adm-part')};
if(!o.titulo) return erro('Informe o título do treinamento.');
if(!o.data) return erro('Informe a data do treinamento.');
if(!o.area) return erro('Escolha a área abordada.');
}
o.arquivoNome = st.arqTmp ? st.arqTmp.nome : '';
o.arquivoDados = st.arqTmp ? st.arqTmp.dados : '';
const lista = admLista(k);
if(st.edit.id){
const alvo = lista.find(y=>y.id===st.edit.id);
Object.assign(alvo, o, {alteradoEm:agoraISO(), alteradoPor:eu().nome});
} else {
lista.push(Object.assign({id:admId(), compartilhado:false, compartilhadoCom:[], soAdm:false,
criadoPor:eu().nome, criadoPorId:eu().id, criadoEm:agoraISO()}, o));
}
salvar(); fechar('ov-adm'); renderAdmin();
toast(st.edit.id ? 'Registro atualizado.' : 'Registro criado no Administrativo.');
}
async function admExcluir(){
const st = admEstado(); if(!st.edit || !st.edit.id) return;
await admApagar(st.edit.bloco, st.edit.id, true);
}
/* Excluir direto pelo botão do cartão, sem passar pela janela de edição. */
async function admExcluirDireto(k, id, ev){
if(ev) ev.stopPropagation();
await admApagar(k, id, false);
}
async function admApagar(k, id, fecharJanela){
if(!admPodeExcluir()){ toast('Só a Administração exclui registros do Administrativo.'); return; }
const x = admLista(k).find(y=>String(y.id)===String(id)); if(!x) return;
const nome = x.sistema || x.titulo || 'este registro';
if(!await confirmar('Excluir "'+nome+'"?'+
(admNoRapido(x)?'\n\nEle também sai do Acesso rápido da equipe.':'')+
'\n\nEssa exclusão não pode ser desfeita.')) return;
if(k==='senhas') S.admSenhas = admSenhas().filter(y=>y.id!==x.id);
else if(k==='docs') S.admDocs = admDocs().filter(y=>y.id!==x.id);
else S.admTreinos = admTreinos().filter(y=>y.id!==x.id);
salvar();
if(fecharJanela) fechar('ov-adm');
renderAdmin();
toast('"'+nome+'" excluído.');
}
/* ---------------- o que a equipe enxerga no Acesso rápido ---------------- */
function admCompartilhados(){
const marca = (k, x) => Object.assign({_bloco:k, _liberado:admLiberadoPraMim(x)}, x);
return admSenhas().filter(admNoRapido).map(x=>marca('senhas',x))
.concat(admDocs().filter(admNoRapido).map(x=>marca('docs',x)))
.concat(admTreinos().filter(admNoRapido).map(x=>marca('treinos',x)));
}
function cardCompartilhado(x){
const k = x._bloco;
const rot = {senhas:'Acesso', docs:'Documento', treinos:'Treinamento'}[k];
const cls = {senhas:'in', docs:'dr', treinos:'ex'}[k];
/* Sem liberação, o cartão existe mas não abre: a equipe vê que o acesso está
cadastrado e sabe a quem pedir, sem enxergar link, arquivo nem senha. */
if(!x._liberado) return cardBloqueado(k, x, rot, cls);
const link = String(x.url||'').trim();
const ver = !!admEstado().verSenha['c'+x.id];
let acoes = '';
if(link) acoes += '↗ Abrir';
if(x.arquivoNome) acoes += '';
if(k==='senhas'){
acoes += '';
}
const detalhe = (k==='senhas' && ver)
? '
Usuário'+esc(x.login||'—')+''+
(x.login?'':'')+'
'+
'
Senha'+esc(x.senha||'—')+''+
'
'
: '';
const sub = k==='senhas' ? (x.categoria||'Sistema do escritório')
: k==='docs' ? (x.etiqueta||'Documento do escritório')
: (x.area||'Treinamento')+(x.data?' · '+fmtD(x.data):'');
/* a etiqueta colorida acompanha o registro também aqui, no Acesso rápido */
const etiq = k==='docs' ? admEtiqueta(x.etiqueta,'Etiqueta do documento',ADM_ETIQUETAS)
: k==='treinos' ? admEtiqueta(x.area,'Área abordada no treinamento',ADM_AREAS) : '';
return '
'+
'
'+({senhas:'🔑',docs:'🗂',treinos:'🎓'}[k])+''+
'
'+esc(x.sistema||x.titulo||'—')+
''+rot+''+etiq+'
'+
'
'+esc(x.obs||x.resumo||sub)+'
'+
'
'+ detalhe +
'
'+(acoes||'Sem link ou arquivo cadastrado.')+'
'+
'
';
}
/* O mesmo cartão, fechado a cadeado.
Quem não foi incluído no compartilhamento não descobre por aqui quem foi, nem
quantas pessoas são: a lista de liberados é assunto da gestão. */
function cardBloqueado(k, x, rot, cls){
const sub = k==='senhas' ? (x.categoria||'Sistema do escritório')
: k==='docs' ? (x.etiqueta||'Documento do escritório')
: (x.area||'Treinamento');
return '
';
}
function admPedirAcesso(id, ev){
if(ev) ev.stopPropagation();
toast('Este registro está liberado só para algumas pessoas. Peça acesso à Administração.');
}
function admVerCompartilhada(id, ev){
if(ev) ev.stopPropagation();
const v=admEstado().verSenha; v['c'+id]=!v['c'+id]; renderRapido();
}
/* =========================================================
IA SCHNEIDER & BAHNERT
Um assistente para a gestão. Trabalha em duas camadas:
1) consultas locais — respondidas aqui mesmo, na hora, sem
nada sair da plataforma (listas, contagens, relatórios);
2) modelo de linguagem — para o que a camada local não sabe
responder, a pergunta e um retrato dos dados vão ao
servidor, que fala com a API da Anthropic.
Só Administração e advogados sócios entram neste menu.
========================================================= */
const IA_MODELOS = [
{k:'claude-sonnet-4-6', n:'Sonnet 4.6 — equilíbrio (recomendado)'},
{k:'claude-haiku-4-5', n:'Haiku 4.5 — mais rápido e econômico'},
{k:'claude-opus-4-8', n:'Opus 4.8 — mais capaz, mais caro'}
];
/* As sugestões seguem o que o perfil de quem está lendo pode consultar —
não adianta oferecer um atalho que a pessoa não tem acesso. */
function iaSugestoes(){
const a = iaAcesso(), s = ['O que está atrasado hoje'];
if(a.clientes) s.push('Telefone do cliente Silva',
'Tabela com nome e telefone dos clientes ativos',
'Clientes por área',
'Mês com mais contratos fechados');
if(a.equipe) s.push('Quantas tarefas cada integrante concluiu');
if(a.financeiro) s.push('Clientes inadimplentes',
'Última parcela paga pelo cliente Souza',
'Dados dos advogados parceiros',
'Resumo financeiro do escritório');
s.push('Meus compromissos desta semana');
return s;
}
function iaEstado(){
if(!UI.ia) UI.ia = {msgs:[], pensando:false, cfg:null, comDoc:false};
return UI.ia;
}
/* ---------------------------------------------------------------
O QUE CADA PERFIL PODE PERGUNTAR
A IA nunca conta o que a pessoa não poderia abrir com as próprias
mãos. Um estagiário não recebe resposta sobre o financeiro, sobre
as metas ou sobre o que é exclusivo de outros perfis — e a recusa
é explícita, para não parecer que o dado não existe.
--------------------------------------------------------------- */
function iaAcesso(){
return {
clientes: pode('ver_clientes'),
equipe: pode('ver_equipe') || ehGestor(),
financeiro: podeFinanceiro(),
parceiros: podeFinanceiro(),
metas: ehGestor(),
admin: ehGestor(),
gestor: ehGestor(),
escopoMeu: escopoMeu()
};
}
const IA_ASSUNTOS = [
{k:'financeiro', rot:'A Gestão Financeira do escritório',
re:/\bfinanceir|faturament|receb[ií]ve|inadimpl|honor[aá]ri|\bcaixa\b|\bparcela|\bpagament|\brepasse|\bdespesa|\blucro|\brpv\b|precat[oó]ri|nota fiscal|\bnfe?\b|\bfatur|\bdevendo\b|\bdevedor/},
{k:'metas', rot:'A aba de Metas e OKR',
re:/\bmetas?\b|\bokr\b|\bobjetivos? macro|\bkpi\b|resultados?[- ]chave/},
{k:'admin', rot:'O menu Administrativo',
re:/\bsenhas?\b|\badministrativ|\btreinament|gest[aã]o de pessoas|\bfeedback|aumento salarial|\bsal[aá]ri/},
{k:'parceiros', rot:'O cadastro de advogados parceiros e associados',
re:/\bparceir|\bassociad/},
{k:'equipe', rot:'O cadastro da equipe',
re:/\bequipe\b|\bintegrantes?\b|\bfuncion[aá]ri|\bcolaboradore?s?\b|quem trabalha/},
{k:'clientes', rot:'A carteira de clientes',
re:/\bcliente|\bcarteira\b|\bcontratos?\b/}
];
function iaBarrar(n){
const a = iaAcesso();
for(let i=0;iVocê não tem acesso a essa informação'+
'
'+esc(assunto)+' está fora do que o perfil '+esc(u.funcao||'—')+' enxerga nesta plataforma — '+
'por isso eu também não respondo sobre isso por aqui.
'+
'
Se esse dado for necessário para o seu trabalho, fale com a Administração: o acesso é liberado '+
'no cadastro da equipe, em Configurações.
',
txtPuro:'Você não tem acesso a essa informação.'};
}
/* ---------------------------------------------------------------
HISTÓRICO DE PESQUISAS
Toda pergunta fica registrada, com autor e data. Ninguém apaga o
que perguntou: só a Administração pode excluir o próprio histórico,
e só ela consulta o dos demais — é o controle interno da equipe.
--------------------------------------------------------------- */
function iaHist(){ return Array.isArray(S.iaHistorico) ? S.iaHistorico : (S.iaHistorico=[]); }
function iaRegistrar(pergunta, resp){
const u = eu()||{};
const r = resp||{};
iaHist().push({
id: 'ia'+Date.now().toString(36)+Math.random().toString(36).slice(2,6),
uid: u.id, nome: u.nome||'—', funcao: u.funcao||'',
em: agoraISO(),
pergunta: String(pergunta||'').slice(0,600),
fonte: String(r.fonte||'').slice(0,160),
negado: !!r.negado,
resumo: String(r.txtPuro||'').replace(/\s+/g,' ').slice(0,400)
});
if(iaHist().length>3000) S.iaHistorico = iaHist().slice(-3000);
salvar();
}
function iaHistPodeVerDeOutros(){ return ehAdmin(); }
function iaHistPodeExcluir(r){ return ehAdmin() && r && r.uid===S.usuario; }
function iaAbrirHist(){
UI.iaHistQuem = iaHistPodeVerDeOutros() ? (UI.iaHistQuem||'eu') : 'eu';
UI.iaHistBusca = '';
renderIaHist();
abrir('ov-ia-hist');
}
function iaHistQuem(v){ UI.iaHistQuem = v; renderIaHistLista(); }
/* só a lista é redesenhada: quem está digitando não perde o foco do campo */
function iaHistBusca(v){ UI.iaHistBusca = v||''; renderIaHistLista(); }
function iaHistLista(){
const q = iaNorm(UI.iaHistBusca||'');
let l = iaHist().slice();
const quem = UI.iaHistQuem||'eu';
if(!iaHistPodeVerDeOutros() || quem==='eu') l = l.filter(r=>r.uid===S.usuario);
else if(quem!=='todos') l = l.filter(r=>String(r.uid)===String(quem));
if(q) l = l.filter(r=> iaNorm(r.pergunta).indexOf(q)>=0 || iaNorm(r.resumo).indexOf(q)>=0 || iaNorm(r.nome).indexOf(q)>=0);
return l.sort((a,b)=>String(b.em).localeCompare(String(a.em)));
}
function renderIaHist(){
const adm = iaHistPodeVerDeOutros();
const lista = iaHistLista();
$('iah-sub').textContent = adm
? 'Como Administração, você consulta o histórico de qualquer integrante e pode excluir apenas o seu próprio.'
: 'Tudo o que você perguntou fica registrado aqui, de forma permanente.';
$('iah-bar').innerHTML =
(adm ? '' : '')+
''+
''+lista.length+' pesquisa(s)';
$('iah-esq').innerHTML = adm
? ''
: '';
renderIaHistLista();
}
function renderIaHistLista(){
const adm = iaHistPodeVerDeOutros();
const lista = iaHistLista();
const cont = $('iah-conta'); if(cont) cont.textContent = lista.length+' pesquisa(s)';
$('iah-corpo').innerHTML =
'
'+(adm
? 'O histórico é permanente para toda a equipe. Só a Administração pode excluir os próprios registros — os dos demais integrantes não podem ser apagados por ninguém.'
: 'O histórico de pesquisas é permanente e não pode ser excluído. Ele serve ao acompanhamento interno do escritório.')+'
');
}
async function iaHistExcluir(id){
const r = iaHist().find(x=>String(x.id)===String(id));
if(!r) return;
if(!iaHistPodeExcluir(r)){ toast('Só a Administração pode excluir o próprio histórico.'); return; }
if(!await confirmar('Excluir esta pesquisa do seu histórico?\nO registro some para sempre.')) return;
S.iaHistorico = iaHist().filter(x=>String(x.id)!==String(id));
salvar(); renderIaHist(); toast('Pesquisa excluída do seu histórico.');
}
async function iaHistLimparMeu(){
if(!ehAdmin()){ toast('Só a Administração pode excluir o próprio histórico.'); return; }
const meus = iaHist().filter(r=>r.uid===S.usuario).length;
if(!meus){ toast('Você ainda não tem pesquisas registradas.'); return; }
if(!await confirmar('Excluir as suas '+meus+' pesquisa(s) do histórico?\nO histórico dos demais integrantes não é afetado.')) return;
S.iaHistorico = iaHist().filter(r=>r.uid!==S.usuario);
salvar(); renderIaHist(); toast('Seu histórico foi limpo.');
}
function iaNorm(s){
return String(s==null?'':s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
}
function montarIA(){
const el = $('pane-ia'); if(!el) return;
const st = iaEstado();
if(!el.dataset.pronto){
el.dataset.pronto = '1';
el.innerHTML =
'
'+
'
'+
'
Schneider e Bahnert Advogados
IA Schneider & Bahnert
'+
'
Pergunte em português qualquer coisa sobre a base do escritório — clientes, parceiros, prazos, equipe, agenda, '+
'atas e finanças. Pode dizer o nome do cliente pela metade: eu devolvo as opções compatíveis. '+
'A resposta vem em texto ou em tabela, pronta para copiar ou baixar. '+
'Cada pessoa recebe resposta apenas sobre o que o seu perfil já enxerga na plataforma, '+
'e toda pergunta fica registrada no histórico.
'+
'
'+
''+
''+
(ehAdmin()?'':'')+
'
'+
'
'+
''+
''+
'
'+
''+
''+
'
'+
'';
}
$('ia-sug').innerHTML = iaSugestoes().map(x=>
'').join('');
const lg=$('ia-logo');
if(lg) lg.innerHTML = (S.cfg && S.cfg.logoIcone) ? '' : LOGO_ICONE;
iaRenderConversa();
iaRodape();
if(st.cfg===null && SRV.ativo && ehAdmin()) iaCarregarCfg();
}
function iaRodape(){
const st=iaEstado(), r=$('ia-rodape'); if(!r) return;
const tem = st.cfg && st.cfg.temChave;
r.innerHTML = 'As consultas diretas — listas, contagens e relatórios — são respondidas aqui dentro, '+
'sem que nenhum dado saia da plataforma.'+
'Você pergunta como '+esc((eu()||{}).funcao||'—')+': só recebe resposta sobre o que esse perfil '+
'já pode abrir na plataforma. Todas as perguntas ficam no histórico de pesquisas, que não pode ser apagado.'+
(tem
? 'Perguntas mais abertas vão ao modelo '+esc((st.cfg.modelo||'')||'—')+' pela API da Anthropic; '+
'senhas nunca são enviadas'+(st.comDoc?' e os CPFs vão junto porque você autorizou':' e os CPFs ficam de fora')+'.'
: 'Para as perguntas mais abertas, a Administração precisa cadastrar uma chave da API em Modelo e chave.');
}
function iaAltura(t){ t.style.height='auto'; t.style.height=Math.min(160, t.scrollHeight)+'px'; }
function iaTecla(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); iaEnviar(); } }
function iaUsarSugestao(t){ $('ia-txt').value=t; iaAltura($('ia-txt')); iaEnviar(); }
function iaLimpar(){ iaEstado().msgs=[]; iaRenderConversa(); }
function iaRenderConversa(){
const st=iaEstado(), c=$('ia-conversa'); if(!c) return;
if(!st.msgs.length && !st.pensando){
c.innerHTML = '
Comece por uma pergunta.'+
'Exemplos: “clientes ativos com telefone”, “quantas tarefas o Fabrício concluiu”, '+
'“contratos fechados em 2026 por área”, “o que vence esta semana”.
':'');
c.scrollTop = c.scrollHeight;
}
function iaTabelaHTML(t, i){
const linhas = t.linhas||[];
const mostra = linhas.slice(0, 200);
return '
'+
'
'+linhas.length+' linha(s)'+(linhas.length>mostra.length?' · mostrando as 200 primeiras':'')+''+
''+
'
'+
'
'+(t.cols||[]).map(c=>'
'+esc(c)+'
').join('')+'
'+
mostra.map(l=>'
'+l.map(v=>'
'+esc(v==null?'':v)+'
').join('')+'
').join('')+
'
';
}
function iaTabelaDe(i){ const m=iaEstado().msgs[i]; return m && m.tabela ? m.tabela : null; }
function iaCopiarTabela(i){
const t=iaTabelaDe(i); if(!t) return;
const txt=[ (t.cols||[]).join('\t') ].concat((t.linhas||[]).map(l=>l.join('\t'))).join('\n');
copiar(txt, 'Tabela', null, true); toast('Tabela copiada — cole no Excel ou no Google Planilhas.');
}
function iaBaixarTabela(i){
const t=iaTabelaDe(i); if(!t) return;
const csv=[(t.cols||[])].concat(t.linhas||[]).map(l=>l.map(c=>'"'+String(c==null?'':c).replace(/"/g,'""')+'"').join(';')).join('\r\n');
baixar('consulta-'+new Date().toISOString().slice(0,10)+'.csv', ''+csv, 'text/csv;charset=utf-8');
toast('CSV gerado.');
}
/* ---------------- envio ---------------- */
async function iaEnviar(){
const st=iaEstado(), campo=$('ia-txt');
const q=(campo.value||'').trim();
if(!q || st.pensando) return;
campo.value=''; iaAltura(campo);
st.msgs.push({quem:'eu', txt:q});
iaRenderConversa();
const local = iaLocal(q);
if(local){
const m = Object.assign({quem:'ia',
fonte: local.negado ? 'Acesso restrito pelo seu perfil' : 'Consulta direta na base — nada saiu da plataforma'}, local);
st.msgs.push(m);
iaRegistrar(q, m);
iaRenderConversa();
return;
}
if(!SRV.ativo || !SRV.token){
const m = {quem:'ia', fonte:'Consulta direta na base',
html:'
Não reconheci essa pergunta como uma consulta direta, e o modelo de linguagem só funciona no endereço do escritório, '+
'com servidor. Tente reformular — por exemplo, “telefone do cliente Silva”, “clientes inadimplentes” '+
'ou “mês com mais contratos fechados”.
',
txtPuro:'Pergunta não reconhecida como consulta direta e sem servidor para o modelo.'};
st.msgs.push(m); iaRegistrar(q, m);
iaRenderConversa(); return;
}
if(!(st.cfg && st.cfg.temChave)){
if(st.cfg===null && ehAdmin()){ await iaCarregarCfg(); }
if(!(st.cfg && st.cfg.temChave)){
const m = {quem:'ia', fonte:'Modelo não configurado',
html:'
Essa pergunta é mais aberta do que as consultas que eu resolvo sozinha aqui dentro. Para respondê-la, é preciso uma '+
'chave da API da Anthropic cadastrada pela Administração em Modelo e chave.
'+
'
Enquanto isso, eu respondo direto a perguntas sobre clientes, tarefas, equipe, agenda, atas e finanças — '+
'experimente uma das sugestões acima.
',
txtPuro:'Modelo de linguagem sem chave cadastrada.'};
st.msgs.push(m); iaRegistrar(q, m);
iaRenderConversa(); return;
}
}
st.pensando=true; $('ia-enviar').disabled=true; iaRenderConversa();
let j=null;
try{
j = await srvChamar('ia-perguntar', {
pergunta: q,
historico: st.msgs.filter(m=>m.quem==='eu'||m.txtPuro).slice(-6).map(m=>({quem:m.quem, txt:m.txt||m.txtPuro||''})),
dados: iaDossie()
});
}catch(e){ j=null; }
st.pensando=false; $('ia-enviar').disabled=false;
let msg;
if(!j || !j.ok){
msg = {quem:'ia', fonte:'Erro', html:'
'+esc((j&&j.msg)||'Não consegui falar com o modelo.')+'
',
txtPuro:(j&&j.msg)||'Erro ao falar com o modelo.'};
} else {
msg = {quem:'ia', fonte:'Modelo '+esc(j.modelo||'')+' · a base que o seu perfil enxerga foi enviada para a resposta',
html: iaMarkdown(j.resposta||''), txtPuro: j.resposta||''};
}
st.msgs.push(msg);
iaRegistrar(q, msg);
iaRenderConversa();
}
/* ---------------- markdown enxuto ---------------- */
function iaMarkdown(txt){
const linhas = String(txt||'').replace(/\r/g,'').split('\n');
let h='', tabela=null, lista=null;
const inline = t => esc(t)
.replace(/\*\*([^*]+)\*\*/g,'$1')
.replace(/(^|[^*])\*([^*]+)\*/g,'$1$2')
.replace(/`([^`]+)`/g,'$1');
const fechaTab = ()=>{
if(!tabela) return;
const corte = tabela.indexOf('');
const cab = corte>=0 ? tabela.slice(0, corte+5) : '';
const resto = corte>=0 ? tabela.slice(corte+5) : tabela;
h += '
A plataforma conversa com a API da Anthropic, a mesma empresa do Claude. A chave é criada em '+
'console.anthropic.com e fica guardada só no servidor do escritório — nunca volta para o navegador. '+
'O uso é cobrado por consulta, direto na conta da Anthropic.
'+
'
'+
campo('full','Chave da API',false,'',null,
cfg.temChave?'Deixe em branco para manter a chave que já está gravada.':'')+
campo('full','Modelo',false,'')+
campo('full','Enviar CPF e documentos ao modelo',false,
'',null,
'Senhas do Meu INSS e senhas de acesso nunca são enviadas, em nenhuma das opções.')+
'
'+
(cfg.temChave?'':'');
abrir('ov-ia-cfg');
}
async function iaSalvarCfg(){
const chave=($('iac-chave').value||'').trim();
const modelo=$('iac-modelo').value;
iaEstado().comDoc = $('iac-doc').value==='1';
let j=null;
try{ j = await srvChamar('ia-salvar', {chave:chave, modelo:modelo}); }catch(e){}
if(!j || !j.ok){ toast((j&&j.msg)||'Não consegui salvar.'); return; }
iaEstado().cfg = {temChave:!!j.temChave, modelo:j.modelo};
fechar('ov-ia-cfg'); iaRodape();
toast('Configuração da IA atualizada.');
}
async function iaApagarChave(){
if(!await confirmar('Remover a chave da API? As perguntas abertas deixam de funcionar; as consultas diretas continuam.')) return;
let j=null;
try{ j = await srvChamar('ia-salvar', {chave:'', apagar:true}); }catch(e){}
if(j && j.ok){ iaEstado().cfg={temChave:false, modelo:j.modelo}; fechar('ov-ia-cfg'); iaRodape(); toast('Chave removida.'); }
}
/* ---------------- retrato dos dados para o modelo ---------------- */
function iaDossie(){
const comDoc = !!iaEstado().comDoc;
const ac = iaAcesso();
const nome = id => { const m=membro(id); return m?m.nome:null; };
const cli = id => { const c=cliente(id); return c?c.nome:null; };
const d = {
hoje: iso(hoje0()),
escritorio: nomeEscritorio(),
perfilDeQuemPergunta: (eu()||{}).funcao||'',
/* o retrato enviado ao modelo é recortado pelo que este perfil enxerga:
o que a pessoa não pode abrir na plataforma também não sai daqui */
equipe: ac.equipe
? S.membros.map(m=>({nome:m.nome, funcao:m.funcao, situacao:m.status, email:m.email||'', telefone:m.tel||''}))
: [{nome:(eu()||{}).nome||'', funcao:(eu()||{}).funcao||'', situacao:'ativo'}],
clientes: (ac.clientes ? S.clientes : []).map(c=>({
nome:c.nome, telefone:c.tel||'', email:c.email||'',
documento: comDoc ? (c.cpfCnpj||'') : undefined,
area:c.area||'', tipoAcao:c.tipoAcao||'', origem:c.origem||'', uf:c.estado||'',
contrato:c.dataContrato||'', processo:c.semProcesso?'':(c.numProcesso||''),
statusProcesso:statusCaso(c),
movimentacoes:(c.movimentacoes||[]).length,
naCarteira: !c.inativo, motivoSaida: c.inativo?(c.motivoSaida||''):'',
menorDeIdade: !!c.menorIdade,
representante: c.menorIdade?{nome:c.repNome||'', relacao:c.repRelacao||''}:undefined
})),
tarefas: S.tarefas.filter(t=>visivel(t) && (t.tipo!=='financeiro' || ac.financeiro)).map(t=>({
tipo:(TIPOS[t.tipo]||{}).kind||t.tipo, descricao:t.descricao, acao:t.acao||'',
status:t.status, prioridade:t.prioridade||'Média',
entrega:t.data||'', inicio:t.hora||'', termino:t.horaFim||'',
responsaveis:(t.responsaveis||[]).map(nome).filter(Boolean),
clientes:(t.clientes||[]).map(cli).filter(Boolean),
criadoPor:t.criadoPor||'', criadoEm:(t.criadoEm||'').slice(0,10),
concluidoEm:(t.concluidoEm||'').slice(0,10),
comentarios:(t.comentarios||[]).length
})),
compromissos: (S.compromissos||[]).map(c=>({
titulo:compOculto(c)?'(particular)':c.titulo, tipo:c.tipo||'', data:c.data||'',
inicio:c.hora||'', termino:c.horaFim||'', concluido:!!c.concluido,
local:compOculto(c)?'':(c.local||''), participantes:(c.participantes||[]).map(nome).filter(Boolean)
})),
atas: (S.atas||[]).filter(podeVerAta).map(a=>({numero:a.numero, tipo:a.tipo, titulo:a.titulo, data:a.data,
participantes:(a.participantes||[]).map(nome).filter(Boolean), etiquetas:a.tags||[],
temRegistro: !!(a.texto&&a.texto.length>20)}))
};
try{
const g = ac.financeiro ? finLerEstado() : null;
if(g){
d.parceiros = (finListaParceiros(g)||[]).map(x=>({nome:x.nome, vinculo:x.tipo, area:x.area||'',
cpf: comDoc ? (x.cpf||'') : undefined, telefone:x.tel||'', pix:x.pix||'', observacoes:x.obs||''}));
const vTot = r => +(r.total!=null ? r.total : ((+r.pos||+r.devido||0) * (+r.parcelas||0))) || 0;
const pago = r => (r.pagamentos||[]).reduce((s,x)=>s+(x&&x.p?(+x.v||0):0),0);
d.financeiro = {
contratosEmAberto:(g.recebiveis||[]).length,
finalizados:(g.finalizados||[]).length,
despesasCadastradas:(g.despesas||[]).length,
totalContratadoEmAberto:(g.recebiveis||[]).reduce((s,r)=>s+vTot(r),0),
totalJaRecebidoNesses:(g.recebiveis||[]).reduce((s,r)=>s+pago(r),0),
contratos:(g.recebiveis||[]).slice(0,400).map(r=>({cliente:r.cliente, area:r.area||'',
valorContratado:vTot(r), jaRecebido:pago(r), parcelas:+r.parcelas||0,
parcelasPagas:(r.pagamentos||[]).filter(x=>x&&x.p).length, forma:r.forma||'',
inicio:r.inicio||'', emitirNF:r.nf||''}))
};
}
}catch(e){}
return d;
}
/* ---------------- camada local: consultas resolvidas aqui ----------------
Entende o essencial do português que a gestão usa no dia a dia. O que
passa daqui vai para o modelo de linguagem. */
function iaPeriodo(n){
const hj = hoje0();
const d2 = (dd,mm,aa)=> aa+'-'+String(mm).padStart(2,'0')+'-'+String(dd).padStart(2,'0');
let m;
m = /\bde\s+(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4})\s+(?:a|ate|até)\s+(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4})/.exec(n);
if(m) return {de:d2(+m[1],+m[2],m[3]), ate:d2(+m[4],+m[5],m[6]), rot:'de '+m[1]+'/'+m[2]+'/'+m[3]+' a '+m[4]+'/'+m[5]+'/'+m[6]};
m = /\b(?:ate|até)\s+(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4})/.exec(n);
if(m) return {ate:d2(+m[1],+m[2],m[3]), rot:'até '+m[1]+'/'+m[2]+'/'+m[3]};
m = /\b(?:desde|a partir de)\s+(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4})/.exec(n);
if(m) return {de:d2(+m[1],+m[2],m[3]), rot:'a partir de '+m[1]+'/'+m[2]+'/'+m[3]};
m = /\bem\s+(\d{2})[\/.-](\d{4})\b/.exec(n);
if(m) return {de:m[2]+'-'+m[1]+'-01', ate:m[2]+'-'+m[1]+'-31', rot:cap1(MESES_NOME[+m[1]-1])+' de '+m[2]};
m = /\b(?:em|de)\s+(20\d{2})\b/.exec(n);
if(m) return {de:m[1]+'-01-01', ate:m[1]+'-12-31', rot:'em '+m[1]};
if(/\b(neste|deste|este|no) ano\b/.test(n)){ const a=hj.getFullYear(); return {de:a+'-01-01', ate:a+'-12-31', rot:'em '+a}; }
if(/\bano passado\b/.test(n)){ const a=hj.getFullYear()-1; return {de:a+'-01-01', ate:a+'-12-31', rot:'em '+a}; }
if(/\b(neste|deste|este|no) m[eê]s\b/.test(n)){
const a=hj.getFullYear(), me=hj.getMonth()+1;
return {de:d2(1,me,a), ate:d2(31,me,a), rot:cap1(MESES_NOME[me-1])+' de '+a};
}
if(/\bm[eê]s passado\b/.test(n)){
const d=new Date(hj.getFullYear(), hj.getMonth()-1, 1), a=d.getFullYear(), me=d.getMonth()+1;
return {de:d2(1,me,a), ate:d2(31,me,a), rot:cap1(MESES_NOME[me-1])+' de '+a};
}
if(/\b(pr[oó]ximo m[eê]s|m[eê]s que vem)\b/.test(n)){
const d=new Date(hj.getFullYear(), hj.getMonth()+1, 1), a=d.getFullYear(), me=d.getMonth()+1;
return {de:d2(1,me,a), ate:d2(31,me,a), rot:cap1(MESES_NOME[me-1])+' de '+a};
}
m = /\b[uú]ltimos?\s+(\d{1,3})\s+dias\b/.exec(n);
if(m){ const d=new Date(hj); d.setDate(d.getDate()-(+m[1])); return {de:iso(d), ate:iso(hj), rot:'nos últimos '+m[1]+' dias'}; }
return null;
}
function iaMembroDe(n){
let achado=null, tam=0;
S.membros.forEach(m=>{
const partes = iaNorm(m.nome).split(/\s+/).filter(x=>x.length>2);
const inteiro = iaNorm(m.nome);
if(n.indexOf(inteiro)>=0 && inteiro.length>tam){ achado=m; tam=inteiro.length; return; }
if(partes.length && n.indexOf(partes[0])>=0 && partes[0].length>tam){ achado=m; tam=partes[0].length; }
});
return achado;
}
function iaAreaDe(n){
let achada='';
CLI_AREAS.forEach(a=>{ const x=iaNorm(a); if(x.length>3 && n.indexOf(x.slice(0,6))>=0) achada=a; });
return achada;
}
/* ---------------------------------------------------------------
BUSCA POR NOME INCOMPLETO
Quem pergunta raramente digita o nome inteiro do cliente. Aqui o
nome é quebrado em palavras e cada uma é procurada; quando mais de
um cadastro se encaixa, a resposta traz as opções compatíveis para
a pessoa escolher, em vez de chutar uma.
--------------------------------------------------------------- */
const IA_STOP = ('de da do das dos e a o os as um uma uns umas ao aos no na nos nas em por para com sem que qual '+
'quais quanto quantos quanta quantas quem qual e me da-me diga informe informa mostre mostra mostrar liste lista '+
'listar traga traz trazer preciso quero saber sobre qualquer todos toda todas todo cadastro cadastrado dados dado '+
'ficha telefone celular whatsapp whats contato email e-mail mail cpf cnpj documento processo numero num area areas '+
'acao acoes cliente clientes parceiro parceiros parceira associado associados socio socios ultima ultimo ultimas '+
'parcela parcelas paga pagas pago pagamento pagamentos inadimplente inadimplentes inadimplencia mes meses contrato '+
'contratos fechado fechados fechou assinado assinados endereco senha inss meu qual qualquer tem esta estao onde '+
'advogado advogados advogada advogadas doutor doutora dra dro vinculo cadastros registro registros ficha fichas '+
'pelo pela pelos pelas dele dela deles delas foi sao era nome nomes sobre entre desde ate apos antes ainda '+
'esse essa este esta isso aquele aquela nosso nossa meus minha minhas seus sua suas mais menos melhor pior '+
'quanto valor valores devendo deve escritorio equipe agenda tarefa tarefas prazo prazos').split(/\s+/);
const IA_STOP_SET = {}; IA_STOP.forEach(w=>{ IA_STOP_SET[w]=true; });
function iaTokens(n){
return iaNorm(n).replace(/[^a-z0-9\s]/g,' ').split(/\s+/)
.filter(w=>w.length>=3 && !IA_STOP_SET[w] && !/^\d+$/.test(w));
}
function iaBuscarPorNome(lista, n, campoNome){
const toks = iaTokens(n); if(!toks.length) return [];
const out=[];
(lista||[]).forEach(x=>{
const bruto = campoNome ? campoNome(x) : x.nome;
const nm = iaNorm(bruto||''); if(!nm) return;
const partes = nm.split(/\s+/);
let pontos = 0, casou = 0;
toks.forEach(t=>{
if(partes.indexOf(t)>=0){ pontos+=4; casou++; }
else if(partes.some(pp=>pp.indexOf(t)===0 && t.length>=4)){ pontos+=3; casou++; }
else if(t.length>=4 && nm.indexOf(t)>=0){ pontos+=1; casou++; }
});
if(casou) out.push({item:x, pontos:pontos, casou:casou});
});
out.sort((a,b)=> b.pontos-a.pontos || String(campoNome?campoNome(a.item):a.item.nome)
.localeCompare(String(campoNome?campoNome(b.item):b.item.nome),'pt-BR'));
return out;
}
/* ---- ficha completa de um cliente ---- */
function iaFichaCliente(c){
const linhas = [];
const linha = (k,v)=>{ linhas.push([k, (v==null||v==='')?'—':String(v)]); };
linha('Nome', c.nome);
linha('CPF/CNPJ', c.cpfCnpj);
linha('Telefone', c.tel);
linha('E-mail', c.email);
linha('Área do Direito', c.area);
linha('Tipo de ação', c.tipoAcao);
linha('Origem', c.origem);
linha('Estado', c.estado ? ((UF_NOME[c.estado]||c.estado)+' ('+c.estado+')') : '');
linha('Assinatura do contrato', c.dataContrato?fmtD(c.dataContrato):'');
linha('Processo', c.semProcesso ? 'não tem processo judicial' : (c.numProcesso||''));
linha('Tribunal', c.tribunal);
linha('Status do processo', statusCaso(c));
linha('Movimentações registradas', (c.movimentacoes||[]).length);
linha('Pasta no Drive', c.semDrive ? 'não há' : (c.linkDrive||''));
linha('Senha do Meu INSS', c.semSenhaInss ? 'não é necessária' : (c.senhaInss ? 'cadastrada (consulte no menu Clientes)' : ''));
linha('Parceiro externo', finPartResumo(c.parceiros));
linha('Participantes do escritório', finPartResumo(c.participantesPct) ||
(c.participantes||[]).map(id=>(membro(id)||{}).nome).filter(Boolean).join(', '));
if(c.menorIdade) linha('Representante', (c.repNome||'—')+(c.repRelacao?' ('+c.repRelacao+')':''));
linha('Situação', c.inativo ? ('fora da carteira — '+(c.motivoSaida||'sem motivo informado')) : 'na carteira');
linha('Observações', c.obs);
linha('Cadastrado por', (c.criadoPor||'—')+(c.criadoEm?' em '+fmtDH(c.criadoEm):''));
return iaResposta('Cadastro de '+c.nome,
'Ficha completa do cliente, como está no menu Clientes.',
['Campo','Conteúdo'], linhas);
}
/* ---- leitura da base financeira para as consultas de dinheiro ---- */
function iaFin(){
let g=null; try{ g=finLerEstado(); }catch(e){}
return g;
}
function iaFinBR(d){ /* 'DD/MM/AAAA' -> Date */
const m=/^(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4})$/.exec(String(d||'').trim());
if(!m) return null;
const x=new Date(+m[3], +m[2]-1, +m[1]); x.setHours(0,0,0,0);
return isNaN(x)?null:x;
}
function iaFinVenc(r,i){
const ini = iaFinBR(r.inicio); if(!ini) return null;
const d = new Date(ini.getFullYear(), ini.getMonth()+i, ini.getDate());
d.setHours(0,0,0,0); return d;
}
function iaReais(v){ return 'R$ '+(+v||0).toLocaleString('pt-BR',{minimumFractionDigits:2, maximumFractionDigits:2}); }
function iaFinParcelas(r){
const n = +r.parcelas||0, P = Array.isArray(r.pagamentos)?r.pagamentos:[];
const devido = (r.devido!=null ? +r.devido : (r.pos!=null?+r.pos:0)) || 0;
const out=[];
for(let i=0;i'+esc(titulo)+'
'+texto+'
', tabela: cols?{cols:cols, linhas:linhas||[]}:null,
txtPuro: titulo+' — '+String(texto).replace(/<[^>]+>/g,'')};
}
function iaAgrupar(itens, chave){
const m={};
itens.forEach(x=>{ const k=chave(x)||'—'; (Array.isArray(k)?k:[k]).forEach(v=>{ m[v]=(m[v]||0)+1; }); });
return Object.keys(m).sort((a,b)=>m[b]-m[a]).map(k=>[k, m[k]]);
}
function iaLocal(pergunta){
const n = iaNorm(pergunta);
if(!n) return null;
/* Pergunta aberta — de redação, análise ou opinião — não é consulta:
essas vão para o modelo de linguagem, que sabe escrever. */
if(/\bescreva|\bredija|\belabore|\bcrie um|\bmonte um|\banalis|\bcompar|\bsugir|\bsugest|\brecomend|\bopini|\bavalie|\bexplique|\bpor que\b|\bestrat[eé]gi|\bplano de|\bpar[aá]grafo|\brascunh|\bmensagem para|\be-?mail para|\binterpret|\bo que voc[eê] acha/.test(n)) return null;
/* antes de qualquer coisa: este perfil pode saber disso? */
const barrado = iaBarrar(n);
if(barrado) return iaNegado(barrado);
const ac = iaAcesso();
const per = iaPeriodo(n);
const conta = /\bquant[oa]s?\b|\bcontagem\b|\btotal de\b|\bn[uú]mero de\b/.test(n);
const hj = iso(hoje0());
/* ---------- advogados parceiros e associados ---------- */
if(/\bparceir|\bassociad/.test(n) && !/tarefa|prazo|audi[eê]nc/.test(n)){
const g = iaFin();
if(!g) return iaResposta('Advogados parceiros e associados',
'Não consegui ler a base financeira neste aparelho. Abra o menu Gestão Financeira uma vez e repita a pergunta.');
let lista = (finListaParceiros(g)||[]).slice();
const soP = /\bparceir/.test(n) && !/\bassociad/.test(n);
const soA = /\bassociad/.test(n) && !/\bparceir/.test(n);
let rot = 'Advogados parceiros e associados';
if(soP){ lista = lista.filter(x=>x.tipo==='PARCEIRO'); rot='Advogados parceiros'; }
if(soA){ lista = lista.filter(x=>x.tipo==='ASSOCIADO'); rot='Advogados associados'; }
const achadosP = iaBuscarPorNome(lista, n);
const filtrou = achadosP.length>0 && achadosP.lengthx.item);
lista.sort((a,b)=>String(a.nome||'').localeCompare(String(b.nome||''),'pt-BR'));
/* quem trabalha com quem: quantos contratos estão ligados a cada parceiro */
const casosDe = nome => S.clientes.filter(c=>(c.parceiros||[]).some(x=>chaveNome(x.nome)===chaveNome(nome))).length;
return iaResposta(rot,
''+lista.length+' cadastro(s)'+(filtrou?' compatível(is) com o nome que você informou':'')+
'. Os dados vêm de Gestão Financeira › Advogados parceiros e associados.',
['Nome','Vínculo','Área','CPF/CNPJ','Telefone','PIX','Clientes ligados','Observações'],
lista.map(x=>[x.nome||'—', x.tipo==='PARCEIRO'?'Parceiro(a)':'Associado(a)', x.area||'—',
x.cpf||'—', x.tel||'—', x.pix||'—', casosDe(x.nome), x.obs||'—']));
}
/* ---------- inadimplentes ---------- */
if(/\binadimpl|\bdevendo\b|\bdevedor|parcelas? vencidas?|\bem atraso com|atrasad[ao]s? (?:no|nos|com o) pagament/.test(n)){
const g = iaFin();
if(!g) return iaResposta('Clientes inadimplentes',
'Não consegui ler a base financeira neste aparelho. Abra o menu Gestão Financeira uma vez e repita a pergunta.');
const hoje = hoje0();
const linhas=[]; let totalVencido=0;
(g.recebiveis||[]).forEach(r=>{
const vencidas = iaFinParcelas(r).filter(x=>!x.pago && x.venc && x.venc.getTime()a+(+x.devido||0),0);
totalVencido += soma;
const maisAntiga = vencidas[0].venc;
const atraso = Math.round((hoje-maisAntiga)/86400000);
linhas.push([r.cliente||'—', r.area||'—', vencidas.length, iaReais(soma),
maisAntiga.toLocaleDateString('pt-BR'), atraso,
(r.forma||'—'), (r.pagamentos||[]).filter(x=>x&&x.p).length+' de '+(+r.parcelas||0)+' paga(s)']);
});
linhas.sort((a,b)=> b[5]-a[5]);
linhas.forEach(l=>{ l[5] = l[5]+' dia(s)'; });
return iaResposta('Clientes com parcela vencida em aberto',
linhas.length
? ''+linhas.length+' contrato(s) com parcela vencida e ainda não paga, somando '+iaReais(totalVencido)+
' em atraso. A lista vem ordenada pelo atraso mais antigo.'
: 'Nenhum contrato com parcela vencida em aberto — a carteira está em dia.',
['Cliente','Área','Parcelas vencidas','Valor em atraso','Vencimento mais antigo','Atraso','Forma','Situação'],
linhas);
}
/* ---------- última parcela paga por um cliente ---------- */
if(/[uú]ltim[ao]s?\s+(?:parcela|pagament|recebiment)|[uú]ltimo pagamento|quando (?:ele|ela|o cliente|a cliente) pagou/.test(n)){
const g = iaFin();
if(!g) return iaResposta('Pagamentos',
'Não consegui ler a base financeira neste aparelho. Abra o menu Gestão Financeira uma vez e repita a pergunta.');
const recs = (g.recebiveis||[]).concat(g.finalizados||[]);
const achadosR = iaBuscarPorNome(recs, n, r=>r.cliente);
if(!achadosR.length){
const todos=[];
recs.forEach(r=>iaFinParcelas(r).filter(x=>x.pago).forEach(x=>{
const d = iaFinBR(x.quando) || x.venc;
if(d) todos.push({cliente:r.cliente||'—', d:d, valor:x.valor, n:x.i+1, tot:+r.parcelas||0});
}));
todos.sort((a,b)=>b.d-a.d);
return iaResposta('Últimos pagamentos recebidos',
todos.length ? 'Os '+Math.min(todos.length,60)+' pagamentos mais recentes. Para um cliente específico, '+
'diga o nome — mesmo incompleto.' : 'Nenhum pagamento registrado ainda.',
['Cliente','Data do pagamento','Parcela','Valor'],
todos.slice(0,60).map(x=>[x.cliente, x.d.toLocaleDateString('pt-BR'),
x.n+(x.tot?' de '+x.tot:''), iaReais(x.valor)]));
}
const nomesR = [];
achadosR.forEach(a=>{ if(nomesR.indexOf(a.item.cliente)<0) nomesR.push(a.item.cliente); });
if(nomesR.length>1){
return iaResposta('Qual destes clientes?',
'Encontrei '+nomesR.length+' cliente(s) compatível(is) com o nome que você informou. '+
'Repita a pergunta com o nome de um deles.',
['Cliente','Área','Parcelas pagas','Valor da parcela'],
achadosR.slice(0,25).map(a=>{ const r=a.item;
return [r.cliente||'—', r.area||'—',
(r.pagamentos||[]).filter(x=>x&&x.p).length+' de '+(+r.parcelas||0),
iaReais(r.devido!=null?r.devido:(r.pos||0))]; }));
}
const rr = achadosR[0].item;
const pagas = iaFinParcelas(rr).filter(x=>x.pago)
.map(x=>({x:x, d:(iaFinBR(x.quando)||x.venc)}))
.filter(y=>y.d).sort((a,b)=>b.d-a.d);
if(!pagas.length) return iaResposta('Pagamentos de '+rr.cliente,
'Este contrato ainda não tem nenhuma parcela paga registrada. São '+(+rr.parcelas||0)+
' parcela(s) previstas de '+iaReais(rr.devido!=null?rr.devido:(rr.pos||0))+'.');
const up = pagas[0];
return iaResposta('Última parcela paga por '+rr.cliente,
'A parcela '+(up.x.i+1)+' de '+(+rr.parcelas||0)+' foi paga em '+up.d.toLocaleDateString('pt-BR')+
', no valor de '+iaReais(up.x.valor)+'. Ao todo são '+pagas.length+
' parcela(s) paga(s) neste contrato.',
['Parcela','Vencimento','Data do pagamento','Valor'],
pagas.map(y=>[(y.x.i+1)+' de '+(+rr.parcelas||0),
y.x.venc?y.x.venc.toLocaleDateString('pt-BR'):'—',
y.d.toLocaleDateString('pt-BR'), iaReais(y.x.valor)]));
}
/* ---------- mês com mais contratos fechados ---------- */
if(/m[eê]s (?:com|de) mais|melhor m[eê]s|m[eê]s que mais|pico de contrat|m[eê]s com maior/.test(n)){
if(!ac.clientes) return iaNegado('A carteira de clientes');
let listaC = S.clientes.filter(c=>c.dataContrato);
if(per) listaC = listaC.filter(c=>(!per.de||c.dataContrato>=per.de) && (!per.ate||c.dataContrato<=per.ate));
const mm={};
listaC.forEach(c=>{ const k=c.dataContrato.slice(0,7); mm[k]=(mm[k]||0)+1; });
const chaves = Object.keys(mm).sort((a,b)=> mm[b]-mm[a] || b.localeCompare(a));
if(!chaves.length) return iaResposta('Contratos por mês',
'Nenhum contrato com data de assinatura registrada'+(per?' '+per.rot:'')+'.');
const rotMes = k => cap1(MESES_NOME[+k.slice(5,7)-1])+' de '+k.slice(0,4);
const top = chaves[0];
const empate = chaves.filter(k=>mm[k]===mm[top]);
return iaResposta('Mês com mais contratos fechados'+(per?' — '+per.rot:''),
(empate.length>1
? 'Empate entre '+empate.map(rotMes).join(', ')+', com '+mm[top]+' contrato(s) cada.'
: 'O melhor mês foi '+rotMes(top)+', com '+mm[top]+' contrato(s) assinado(s).')+
' Abaixo, o ranking completo de '+listaC.length+' contrato(s).',
['Mês','Contratos fechados'],
chaves.map(k=>[rotMes(k), mm[k]]));
}
/* ---------- um cliente específico, pelo nome ainda que incompleto ---------- */
if(ac.clientes){
const pediuFicha = /\btelefone|\bcelular|\bwhats|\bcontato\b|\be-?mail|\bcpf\b|\bcnpj\b|\bdocumento|\bprocesso\b|dados (?:d[oae]|sobre)|\bficha\b|cadastro d|\bendere[cç]|\bmeu inss|qual [eé] [oa] /.test(n);
const coletivo = /por [aá]rea|por origem|por m[eê]s|por ano|por tipo|por motivo|\bquantos\b|\bquantas\b|todos os |todas as |\blistar?\b|\bativos\b|\binativos\b|\bcarteira\b/.test(n);
if(pediuFicha && !coletivo){
const achadosC = iaBuscarPorNome(S.clientes, n);
if(achadosC.length===1) return iaFichaCliente(achadosC[0].item);
if(achadosC.length>1){
const bons = achadosC.filter(a=>a.pontos>=achadosC[0].pontos);
if(bons.length===1) return iaFichaCliente(bons[0].item);
return iaResposta('Encontrei mais de um cliente com esse nome',
''+achadosC.length+' cadastro(s) compatível(is) com o que você digitou. '+
'Repita a pergunta com o nome completo do que você procura — ou aproveite a tabela abaixo, '+
'que já traz os dados de contato de cada um.',
['Nome','Telefone','E-mail','CPF/CNPJ','Área','Processo','Situação'],
achadosC.slice(0,25).map(a=>{ const c=a.item;
return [c.nome, c.tel||'—', c.email||'—', c.cpfCnpj||'—', c.area||'—',
c.semProcesso?'sem processo':(c.numProcesso||'—'),
c.inativo?'fora da carteira':'na carteira']; }));
}
}
}
/* ---------- equipe ---------- */
if(/\bequipe\b|\bintegrantes?\b|\bfuncion[aá]rios?\b|\bcolaboradores?\b|\bquem trabalha\b/.test(n) && !/tarefa|prazo/.test(n)){
const lista = S.membros.slice().sort((a,b)=>a.nome.localeCompare(b.nome,'pt-BR'));
return iaResposta('Equipe do escritório',
''+lista.length+' integrante(s) cadastrado(s), sendo '+lista.filter(m=>m.status==='ativo').length+' ativo(s).',
['Nome','Função','Situação','E-mail','Telefone'],
lista.map(m=>[m.nome, m.funcao, ({ativo:'Ativo',pendente:'Aguardando liberação',inativo:'Inativo'})[m.status]||m.status, m.email||'—', m.tel||'—']));
}
/* ---------- financeiro ---------- */
if(/\bfinanceir|faturamento|receb[ií]vel|receb[ií]veis|inadimpl|honor[aá]rio|caixa\b/.test(n)){
let g=null; try{ g=finLerEstado(); }catch(e){}
if(!g) return iaResposta('Gestão Financeira','Não consegui ler a base financeira neste aparelho. Abra o menu Gestão Financeira uma vez e repita a pergunta.');
const vTot = r => +(r.total!=null ? r.total : ((+r.pos||+r.devido||0) * (+r.parcelas||0))) || 0;
const pago = r => (r.pagamentos||[]).reduce((s,x)=>s+(x&&x.p?(+x.v||0):0),0);
const soma = arr => (arr||[]).reduce((s,r)=>s+vTot(r),0);
const rec=g.recebiveis||[], fin=g.finalizados||[], des=g.despesas||[];
const reais = v => 'R$ '+(+v||0).toLocaleString('pt-BR',{minimumFractionDigits:2, maximumFractionDigits:2});
const linhas = rec.slice().sort((a,b)=>vTot(b)-vTot(a))
.map(r=>[r.cliente||'—', r.area||'—', reais(vTot(r)), reais(pago(r)),
(r.parcelas||0)+'x', r.forma||'—',
(r.pagamentos||[]).filter(x=>x&&x.p).length+' paga(s)']);
const recebido = rec.reduce((s,r)=>s+pago(r),0);
return iaResposta('Retrato da Gestão Financeira',
''+rec.length+' contrato(s) em aberto, somando '+reais(soma(rec))+' contratados, dos quais '+
''+reais(recebido)+' já entraram. Há '+fin.length+' contrato(s) finalizado(s) e '+
''+des.length+' despesa(s) cadastrada(s). Os números do mês, com gráficos, ficam no menu Gestão Financeira.',
['Cliente','Área','Valor contratado','Já recebido','Parcelas','Forma','Parcelas pagas'], linhas);
}
/* ---------- atas ---------- */
if(/\bata[s]?\b|\bregistros? de reuni/.test(n)){
let lista = (S.atas||[]).filter(a=>podeVerAta(a));
if(per) lista = lista.filter(a=>(!per.de||a.data>=per.de) && (!per.ate||a.data<=per.ate));
const rot = {gestao:'Gestão', equipe:'Equipe', individual:'Individual'};
return iaResposta('Atas e registros'+(per?' — '+per.rot:''),
''+lista.length+' ata(s) que você pode ver.',
['Nº','Tipo','Título','Data','Participantes','Redigida'],
lista.sort((a,b)=>String(b.data).localeCompare(String(a.data))).map(a=>[
a.numero||'—', rot[a.tipo]||a.tipo, a.titulo||'—', a.data?fmtD(a.data):'—',
(a.participantes||[]).map(id=>{const m=membro(id);return m?m.nome:'';}).filter(Boolean).join(', ')||'—',
(a.texto&&a.texto.length>20)?'sim':'ainda não'
]));
}
/* ---------- agenda / compromissos ---------- */
if(/\bcompromisso|\bagenda\b|\breuni[aã]o|\breuni[oõ]es/.test(n)){
const quem = iaMembroDe(n);
let lista = (S.compromissos||[]).slice();
if(per) lista = lista.filter(c=>(!per.de||c.data>=per.de) && (!per.ate||c.data<=per.ate));
else lista = lista.filter(c=>c.data>=hj);
if(quem) lista = lista.filter(c=>(c.participantes||[]).indexOf(quem.id)>=0);
lista.sort((a,b)=>String(a.data+a.hora).localeCompare(String(b.data+b.hora)));
return iaResposta('Compromissos'+(quem?' de '+quem.nome:'')+(per?' — '+per.rot:' a partir de hoje'),
''+lista.length+' compromisso(s).',
['Data','Início','Término','Tipo','Título','Local','Participantes'],
lista.map(c=>[fmtD(c.data), c.hora||'—', c.horaFim||'—', c.tipo||'—',
c.particular?'(particular)':(c.titulo||'—'), c.local||'—',
(c.participantes||[]).map(id=>{const m=membro(id);return m?m.nome:'';}).filter(Boolean).join(', ')||'—']));
}
/* ---------- tarefas e prazos ---------- */
if(/\btarefa|\bprazo|\bregistros?\b|\bconclu[ií]|\batrasad|\bvenc|\bpend[eê]nc|\bperícia|\bpericia|\batendimento/.test(n)){
const quem = iaMembroDe(n);
let lista = S.tarefas.filter(visivel);
let rot=[];
const soConcluidas = /conclu[ií]/.test(n);
const soAbertas = /\bem aberto\b|\bpendente|\bandamento\b|\bfazer\b/.test(n);
const atrasadas = /\batrasad|\bvencid/.test(n);
const venceHoje = /\bvence hoje\b|\bpara hoje\b|\bhoje\b/.test(n) && !soConcluidas;
const semana = /\besta semana\b|\bpr[oó]ximos 7\b|\bsemana\b/.test(n);
if(quem){ lista = lista.filter(t=>(t.responsaveis||[]).indexOf(quem.id)>=0); rot.push('de '+quem.nome); }
if(soConcluidas){ lista = lista.filter(t=>t.status===CONCLUIDA); rot.push('concluídas'); }
else if(soAbertas){ lista = lista.filter(t=>t.status!==CONCLUIDA); rot.push('em aberto'); }
if(atrasadas){ lista = lista.filter(t=>t.status!==CONCLUIDA && t.data && t.datat.data===hj && t.status!==CONCLUIDA); rot.push('com entrega hoje'); }
if(semana && !atrasadas && !venceHoje){
const f=new Date(hoje0()); f.setDate(f.getDate()+7);
lista = lista.filter(t=>t.status!==CONCLUIDA && t.data>=hj && t.data<=iso(f)); rot.push('nos próximos 7 dias');
}
/* "tarefa" é como o escritório chama qualquer registro — só vira filtro de
tipo quando a pergunta diz "do tipo tarefa". Os demais tipos são
específicos o bastante para filtrar direto. */
Object.keys(TIPOS).forEach(k=>{
const kk=iaNorm(TIPOS[k].kind);
if(/registro/.test(kk)) return;
const generico = (k==='tarefa') && !/do tipo tarefa|tipo tarefa/.test(n);
if(generico) return;
if(new RegExp('\\b'+kk.slice(0,7)).test(n)){ lista=lista.filter(t=>t.tipo===k); rot.push('do tipo '+TIPOS[k].kind); }
});
if(per){
const campo = soConcluidas ? (t=>(t.concluidoEm||'').slice(0,10)) : (t=>t.data||'');
lista = lista.filter(t=>{ const d=campo(t); return d && (!per.de||d>=per.de) && (!per.ate||d<=per.ate); });
rot.push(per.rot);
}
const titulo = 'Registros '+(rot.join(' · ')||'do escritório');
if(/por respons[aá]vel|por integrante|por pessoa|cada integrante|cada um|por membro/.test(n)){
const linhas = iaAgrupar(lista, t=>(t.responsaveis||[]).map(id=>{const m=membro(id);return m?m.nome:'—';}));
return iaResposta(titulo+' — por responsável',
''+lista.length+' registro(s), distribuídos assim:', ['Responsável','Registros'], linhas);
}
if(/por tipo/.test(n)){
return iaResposta(titulo+' — por tipo', ''+lista.length+' registro(s).', ['Tipo','Registros'],
iaAgrupar(lista, t=>(TIPOS[t.tipo]||{}).kind||t.tipo));
}
if(/por status|por situa/.test(n)){
return iaResposta(titulo+' — por status', ''+lista.length+' registro(s).', ['Status','Registros'],
iaAgrupar(lista, t=>t.status));
}
lista.sort((a,b)=>String(a.data||'').localeCompare(String(b.data||'')));
const texto = conta
? 'São '+lista.length+' registro(s)'+(quem?' sob a responsabilidade de '+esc(quem.nome)+'':'')+'.'
: ''+lista.length+' registro(s) no recorte pedido.';
return iaResposta(titulo, texto,
['Tipo','Descrição','Cliente','Responsável','Entrega','Horário','Status','Prioridade','Concluído em'],
lista.map(t=>[(TIPOS[t.tipo]||{}).kind||t.tipo, t.descricao||'—',
(t.clientes||[]).map(id=>{const c=cliente(id);return c?c.nome:'';}).filter(Boolean).join(', ')||(t.semCliente?'sem cliente':'—'),
nomesResp(t).join(', ')||'—', t.data?fmtD(t.data):'—', faixaHora(t)||'—', t.status, t.prioridade||'Média',
t.concluidoEm?fmtD(String(t.concluidoEm).slice(0,10)):'—']));
}
/* ---------- clientes ---------- */
if(/\bcliente|\bcarteira\b|\bcontrato|\bfelicita|\baniversári|\banivers|\bmenor(es)? de idade\b|\bdesist/.test(n)){
let lista = S.clientes.slice();
let rot=[];
const querInativos = /\binativ|\bsa[ií]ram|\bsaiu\b|\bdesist|\bfora da carteira\b|\bperdid/.test(n);
const querAtivos = /\bativ/.test(n) && !querInativos;
if(querInativos){ lista = lista.filter(c=>c.inativo); rot.push('fora da carteira'); }
else if(querAtivos){ lista = lista.filter(c=>!c.inativo); rot.push('ativos'); }
const area = iaAreaDe(n);
if(area){ lista = lista.filter(c=>c.area===area); rot.push('de '+area); }
if(/menor(es)? de idade|\bmenores\b/.test(n)){ lista = lista.filter(c=>c.menorIdade); rot.push('menores de idade'); }
if(/sem processo/.test(n)){ lista = lista.filter(c=>c.semProcesso); rot.push('sem processo judicial'); }
if(/com processo|processo judicial/.test(n) && !/sem processo/.test(n)){ lista = lista.filter(c=>!c.semProcesso); rot.push('com processo'); }
if(per){ lista = lista.filter(c=>c.dataContrato && (!per.de||c.dataContrato>=per.de) && (!per.ate||c.dataContrato<=per.ate));
rot.push('com contrato '+per.rot); }
if(/por motivo|motivos? de sa[ií]da|por que sa[ií]/.test(n)){
const foras = lista.filter(c=>c.inativo);
return iaResposta('Saídas da carteira por motivo',
''+foras.length+' cliente(s) fora da carteira.', ['Motivo','Clientes'],
iaAgrupar(foras, c=>c.motivoSaida||'Sem motivo informado'));
}
if(/por [aá]rea/.test(n)) return iaResposta('Clientes por área '+(rot.length?'('+rot.join(' · ')+')':''),
''+lista.length+' cliente(s).', ['Área','Clientes'], iaAgrupar(lista, c=>c.area||'Sem área'));
if(/por origem/.test(n)) return iaResposta('Clientes por origem', ''+lista.length+' cliente(s).',
['Origem','Clientes'], iaAgrupar(lista, c=>c.origem||'Sem origem'));
if(/por tipo de a[cç][aã]o|por a[cç][aã]o/.test(n)) return iaResposta('Clientes por tipo de ação',
''+lista.length+' cliente(s).', ['Tipo de ação','Clientes'], iaAgrupar(lista, c=>c.tipoAcao||'Sem ação'));
if(/por m[eê]s|por ano/.test(n)){
const m={};
lista.forEach(c=>{ const k=c.dataContrato?c.dataContrato.slice(0,7):'zzz'; m[k]=(m[k]||0)+1; });
const linhas = Object.keys(m).sort().map(k=>[
k==='zzz' ? 'Sem contrato' : cap1(MESES_NOME[+k.slice(5,7)-1])+'/'+k.slice(0,4), m[k]]);
return iaResposta('Contratos por mês'+(rot.length?' — '+rot.join(' · '):''),
''+lista.length+' cliente(s).', ['Mês do contrato','Clientes'], linhas);
}
/* colunas conforme o que foi pedido */
const cols=['Nome'], pega=[c=>c.nome];
const pediu = x => new RegExp(x).test(n);
const soContato = pediu('telefone|whats|celular|contato|felicita|anivers');
if(soContato || !pediu('cpf|email|e-mail|processo')){ cols.push('Telefone'); pega.push(c=>c.tel||'—'); }
if(pediu('e-?mail')){ cols.push('E-mail'); pega.push(c=>c.email||'—'); }
if(pediu('cpf|documento')){ cols.push('CPF/CNPJ'); pega.push(c=>c.cpfCnpj||'—'); }
if(pediu('processo')){ cols.push('Processo'); pega.push(c=>c.semProcesso?'sem processo':(c.numProcesso||'—')); }
if(!soContato || pediu('[aá]rea')){ cols.push('Área'); pega.push(c=>c.area||'—'); }
if(!soContato){ cols.push('Tipo de ação'); pega.push(c=>c.tipoAcao||'—'); }
cols.push('Contrato'); pega.push(c=>c.dataContrato?fmtD(c.dataContrato):'—');
cols.push('Situação'); pega.push(c=>c.inativo?('fora — '+(c.motivoSaida||'sem motivo')):'na carteira');
if(lista.some(c=>c.menorIdade)){ cols.push('Representante'); pega.push(c=>c.menorIdade?(c.repNome||'—')+(c.repRelacao?' ('+c.repRelacao+')':''):'—'); }
lista.sort((a,b)=>String(a.nome).localeCompare(String(b.nome),'pt-BR'));
const titulo = 'Clientes'+(rot.length?' — '+rot.join(' · '):'');
const texto = conta
? 'São '+lista.length+' cliente(s) no recorte pedido.'
: ''+lista.length+' cliente(s). A tabela sai em CSV pelo botão acima dela.';
return iaResposta(titulo, texto, cols, lista.map(c=>pega.map(f=>f(c))));
}
return null;
}
/* =========================================================
MODAIS, DROPDOWNS E INICIALIZAÇÃO
========================================================= */
/* O visualizador de artefatos ignora confirm() e devolve "não" sem mostrar nada
— por isso a confirmação é uma janela da própria plataforma. Devolve uma
promessa: quem chama usa "await confirmar(...)". */
let CONF_RESOLVE=null;
function confirmar(msg, opts){
opts = opts || {};
const linhas = String(msg==null?'':msg).split('\n').map(l=>l.trim());
const titulo = opts.titulo || linhas[0] || 'Confirmar?';
const corpo = (opts.titulo ? linhas : linhas.slice(1)).filter(Boolean);
const perigo = opts.perigo!==undefined ? opts.perigo
: /excluir|apagar|remover|descartar|zerar|perder|não pode ser desfeit/i.test(msg||'');
$('cf-kind').textContent = opts.tipo || (perigo?'Ação sem volta':'Confirmação');
$('cf-tit').textContent = titulo;
$('cf-txt').innerHTML = corpo.map(l=>'
'+esc(l)+'
').join('');
$('cf-ok').textContent = opts.ok || (perigo?'Sim, continuar':'Confirmar');
$('cf-cancel').textContent = opts.cancelar || 'Cancelar';
document.querySelector('#ov-confirma .modal').classList.toggle('perigo', !!perigo);
return new Promise(resolve=>{
if(CONF_RESOLVE) CONF_RESOLVE(false); /* nunca deixa uma promessa órfã */
CONF_RESOLVE = resolve;
abrir('ov-confirma');
setTimeout(()=>{ try{ $('cf-ok').focus(); }catch(e){} }, 70);
});
}
function fecharConfirma(v){
const r=CONF_RESOLVE; CONF_RESOLVE=null;
fechar('ov-confirma');
if(r) r(!!v);
}
function abrir(id){ const ov=$(id); ov.classList.add('on');
document.body.style.overflow='hidden'; document.body.classList.add('modalaberto');
ov.scrollTop=0; const bd=ov.querySelector('.m-bd'); if(bd) bd.scrollTop=0;
const cm=ov.querySelector('.cfg-main'); if(cm) cm.scrollTop=0; }
function fechar(id){
if(id==='ov-tarefa') capturarRascunho();
if(id==='ov-cliente') capturarRascunhoCliente();
if(id==='ov-ata') descartarAtaVazia();
if(id==='ov-confirma' && CONF_RESOLVE){ const r=CONF_RESOLVE; CONF_RESOLVE=null; r(false); }
$(id).classList.remove('on');
if(!document.querySelector('.ov.on')){ document.body.style.overflow='';
document.body.classList.remove('modalaberto'); } }
function toggleDD(id,e){ if(e) e.stopPropagation();
const el=$(id), aberto=el.classList.contains('on'); closeDD(); if(!aberto) el.classList.add('on'); }
function closeDD(){ document.querySelectorAll('.dd-menu.on').forEach(m=>m.classList.remove('on')); }
document.addEventListener('click', ()=>{ closeDD(); fecharMenuStatus(); });
window.addEventListener('scroll', fecharMenuStatus, true);
/* o destaque de erro sai assim que a pessoa corrige o campo */
['input','change'].forEach(ev=>document.addEventListener(ev, e=>{
const f = e.target && e.target.closest && e.target.closest('.f.err');
if(f) f.classList.remove('err');
}, true));
document.addEventListener('keydown', e=>{
if(e.key==='Escape'){
const abertos=[...document.querySelectorAll('.ov.on')];
fecharMenuStatus();
if(abertos.length){ fechar(abertos[abertos.length-1].id); closeDD(); return; }
closeDD(); sairFinFull();
}
});
document.querySelectorAll('.ov').forEach(ov=>ov.addEventListener('mousedown', e=>{ if(e.target===ov) fechar(ov.id); }));
window.addEventListener('scroll', ()=>$('topbar').classList.toggle('scrolled', window.scrollY>4));
/* anexos: clique e arrastar */
$('t-drop').addEventListener('click', ()=>$('t-file').click());
$('t-file').addEventListener('change', e=>{ receberArquivos(e.target.files); e.target.value=''; });
['dragenter','dragover'].forEach(ev=>$('t-drop').addEventListener(ev, e=>{ e.preventDefault(); $('t-drop').classList.add('over'); }));
['dragleave','drop'].forEach(ev=>$('t-drop').addEventListener(ev, e=>{ e.preventDefault(); $('t-drop').classList.remove('over'); }));
$('t-drop').addEventListener('drop', e=>{ if(e.dataTransfer.files.length) receberArquivos(e.dataTransfer.files); });
$('mb-nome').addEventListener('input', fotoPrev);
/* ======================================================================
MOTOR DE DOCUMENTOS — lê .docx, troca os marcadores e junta tudo num
arquivo só. Um .docx é um ZIP: aqui ele é aberto, editado e regravado
sem biblioteca externa, usando só o que o navegador já oferece.
====================================================================== */
const _TXT = new TextDecoder('utf-8');
const _ENC = new TextEncoder();
/* ---- CRC32, exigido pelo formato ZIP ---- */
const _CRCT = (()=>{ const t=new Uint32Array(256);
for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c = c&1 ? 0xEDB88320 ^ (c>>>1) : c>>>1; t[n]=c>>>0; }
return t; })();
function crc32(b){ let c=0xFFFFFFFF; for(let i=0;i>>8); return (c^0xFFFFFFFF)>>>0; }
/* ---- leitura do ZIP ---- */
async function zipLer(buf){
const d = new DataView(buf), b = new Uint8Array(buf);
let fim = -1;
for(let i=b.length-22; i>=0 && i>b.length-66000; i--){ if(d.getUint32(i,true)===0x06054b50){ fim=i; break; } }
if(fim<0) throw new Error('Arquivo .docx inválido (não parece um Word moderno).');
const total = d.getUint16(fim+10,true);
let p = d.getUint32(fim+16,true);
const saida = new Map();
for(let i=0;is+x.length,0);
const eocd=new Uint8Array(22), ev=new DataView(eocd.buffer);
ev.setUint32(0,0x06054b50,true); ev.setUint16(8,mapa.size,true); ev.setUint16(10,mapa.size,true);
ev.setUint32(12,cTam,true); ev.setUint32(16,deslocamento,true);
const tudo=[...partes,...central,eocd];
const tam=tudo.reduce((s,x)=>s+x.length,0);
const out=new Uint8Array(tam); let o=0;
tudo.forEach(x=>{ out.set(x,o); o+=x.length; });
return out;
}
/* ---- texto ---- */
const _lerTxt = u8 => _TXT.decode(u8);
const _gravTxt = s => _ENC.encode(s);
function escXml(v){ return String(v==null?'':v)
.replace(/&/g,'&').replace(//g,'>'); }
function escRx(c){ return c.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); }
/* O Word costuma quebrar {{NOME}} em vários pedaços de formatação; o padrão
abaixo aceita marcações XML entre as letras do marcador. */
/* Marcador que sai em branco deixa uma marca invisível no lugar. É por ela
que, mais adiante, se descobre qual linha existia só para o marcador — e
some junto com ele, em vez de virar uma linha (ou uma folha) vazia. */
const MARCA_VAZIA = '\uE000';
function trocarMarcadores(xml, campos){
Object.keys(campos).forEach(k=>{
const alvo='{{'+k+'}}';
/* o padrão aceita marcação entre as letras, mas nunca depois do último
caractere — senão engoliria a tag que fecha o texto e quebraria o XML */
const rx=new RegExp(alvo.split('').map(escRx).join('(?:<[^>]*>)*'),'g');
const v = escXml(campos[k]);
xml = xml.replace(rx, v==='' ? MARCA_VAZIA : v);
});
return xml;
}
function limparMarcadoresRestantes(xml){
return xml.replace(/\{(?:<[^>]*>)*\{(?:[A-Z0-9_]|<[^>]*>)+\}(?:<[^>]*>)*\}/g, MARCA_VAZIA);
}
/* ---- junção ---- */
/* Cada .docx declara na raiz os prefixos que usa (w14, mc, wps, v…). Ao trazer
o corpo de um modelo para dentro de outro, os prefixos que só existiam lá
ficariam sem declaração — e o Word recusa o arquivo. Aqui eles são levados
junto. */
function nsDaRaiz(xml){
const m = /]*)>/.exec(xml);
return m ? m[1] : '';
}
function juntarNamespaces(baseXml, outroXml){
const daBase = nsDaRaiz(baseXml);
const doOutro = nsDaRaiz(outroXml);
if(!daBase || !doOutro) return baseXml;
const faltando = [];
(doOutro.match(/xmlns:[A-Za-z0-9_.-]+="[^"]*"/g)||[]).forEach(dec=>{
const pref = dec.slice(0, dec.indexOf('='));
if(daBase.indexOf(pref+'=')<0 && faltando.indexOf(dec)<0) faltando.push(dec);
});
/* mc:Ignorable é uma lista de prefixos: as duas listas viram uma */
const ig = t => ((/mc:Ignorable="([^"]*)"/.exec(t)||[])[1]||'').split(/\s+/).filter(Boolean);
const igJunto = ig(daBase).concat(ig(doOutro)).filter((v,i,a)=>a.indexOf(v)===i);
let novoAtr = daBase + (faltando.length ? ' '+faltando.join(' ') : '');
if(igJunto.length){
novoAtr = /mc:Ignorable="/.test(novoAtr)
? novoAtr.replace(/mc:Ignorable="[^"]*"/, 'mc:Ignorable="'+igJunto.join(' ')+'"')
: novoAtr + ' mc:Ignorable="'+igJunto.join(' ')+'"';
}
if(novoAtr === daBase) return baseXml;
return baseXml.replace(/]*>/, '');
}
function corpoDe(xml){
const i=xml.indexOf(''); const f=xml.lastIndexOf('');
if(i<0||f<0) return '';
let corpo=xml.slice(i+8,f);
corpo=corpo.replace(//g,''); /* a seção que vale é a do primeiro */
return corpo;
}
const QUEBRA = '';
/* ---------------------------------------------------------------
PÁGINAS EM BRANCO
Modelo que termina com linhas vazias, marcador que sai em branco,
quebra de página que o autor deixou no fim do arquivo: juntando as
peças, tudo isso vira folha em branco no meio do documento. Antes de
fechar o arquivo, essas sobras são retiradas.
--------------------------------------------------------------- */
/* ---------------------------------------------------------------
LIMPEZA DO DOCUMENTO PRONTO
Trabalhar o XML do Word com expressão regular é enganoso: um
parágrafo pode estar dentro de uma célula de tabela ou de uma caixa
de texto, e apagá-lo ali deixa a célula sem conteúdo — o Word recusa
o arquivo inteiro e mostra "erro ao tentar abrir". Por isso a limpeza
passou a ser feita sobre a árvore do documento, mexendo apenas nos
parágrafos que são filhos diretos do corpo.
--------------------------------------------------------------- */
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
function xmlLer(xml){
try{
const doc = new DOMParser().parseFromString(xml, 'application/xml');
if(doc.getElementsByTagName('parsererror').length) return null;
if(!doc.documentElement) return null;
return doc;
}catch(e){ return null; }
}
const CAB_XML = '\r\n';
function xmlEscrever(doc){
const t = new XMLSerializer().serializeToString(doc);
return /^<\?xml/.test(t) ? t : CAB_XML + t;
}
function _elem(el, nome){ return el.getElementsByTagNameNS(NS_W, nome); }
/* imagem, fórmula, campo, caixa de texto ou nota contam como conteúdo */
function pVazioDOM(p){
const cheios = ['drawing','pict','object','fldChar','instrText','sym','footnoteReference','endnoteReference'];
for(let i=0;i=0;i--) if(_ehQuebraPagina(brs[i])) return brs[i];
return null;
}
/* Onde a quebra está dentro do parágrafo muda tudo: uma quebra no fim já
virou a página, e outra logo depois criaria uma folha vazia; uma quebra
no começo é que abre a página do texto que vem em seguida. */
const _DEPOIS = 4; /* Node.DOCUMENT_POSITION_FOLLOWING */
function _conteudos(p){
const out = [];
const ts = _elem(p,'t');
for(let i=0;i{
const l = _elem(p,k);
for(let i=0;i dentro do . Um título com essa marcação, logo
depois da quebra que a plataforma acrescenta, dá duas páginas — uma delas
em branco. */
function abrePorEstilo(n){
if(!n || n.localName!=='p') return false;
const pr = _elem(n,'pageBreakBefore');
if(!pr.length) return false;
const v = pr[0].getAttributeNS(NS_W,'val') || pr[0].getAttribute('w:val');
return !(v==='0' || v==='false' || v==='off');
}
/* O trecho anterior já virou a página sozinho? Vale para parágrafo e para
tabela, cuja última linha também pode carregar a quebra. */
function terminaVirandoPagina(n){
if(!n || n.nodeType!==1) return false;
if(n.localName==='p') return fechaEmQuebra(n);
if(n.localName==='tbl'){
const ps = n.getElementsByTagNameNS(NS_W,'p');
for(let i=ps.length-1;i>=0;i--){
if(_conteudos(ps[i]).length || temQuebraDOM(ps[i])) return fechaEmQuebra(ps[i]);
}
}
return false;
}
/* Restou só o rótulo do campo? "Profissão:", "RG: —", "Órgão emissor -"
viram lixo quando o valor não entra. Exige que o parágrafo tivesse mesmo
um marcador vazio, que não haja imagem nem número nele e que o texto
termine na pontuação do rótulo — assim uma linha como "OUTORGANTE:", que
apresenta o parágrafo seguinte, nunca é confundida com sobra. */
function soSobrouRotulo(p){
const cheios = ['drawing','pict','object','fldChar','instrText'];
for(let i=0;i 60) return false;
if(/[0-9]/.test(txt)) return false;
return /[:;,\-–—=\/|]$/.test(txt);
}
function quebraDeAbertura(p){
const br = _primeiraQuebra(p);
if(!br) return null;
const c = _conteudos(p);
for(let i=0;i=0) comMarca.push(ps[i]);
/* 2) a marca invisível some do texto, em toda parte */
const ts = doc.getElementsByTagNameNS(NS_W,'t');
for(let i=0;i=0) ts[i].textContent = v.split(MARCA_VAZIA).join('');
}
/* 3) a linha que existia só para o marcador some junto — mas somente no
corpo do documento: dentro de tabela ou caixa de texto ela permanece.
Some também a linha que virou rótulo órfão: "Profissão:" ou "RG: —"
sozinhos, sem nada depois, são resto de um campo que não entra. */
comMarca.forEach(p=>{
if(p.parentNode !== body) return;
if(temQuebraDOM(p)) return;
if(pVazioDOM(p)){ body.removeChild(p); return; }
if(soSobrouRotulo(p)) body.removeChild(p);
});
/* 4) folhas em branco
Três coisas diferentes viram folha em branco, e cada uma pede um
tratamento próprio:
· o parágrafo que é SÓ uma quebra de página — pode ser descartado;
· o parágrafo que tem TEXTO e também uma quebra — o texto fica, e é
a quebra de dentro dele que sai, quando sobra;
· as linhas vazias que se acumulam antes e depois da quebra.
A procuração é o caso clássico: ela costuma terminar com uma quebra de
página depois da assinatura, e a quebra que a plataforma acrescenta
para separar as peças criava uma segunda folha vazia. */
const lista = [];
for(let i=0;i n.localName==='p';
const comQuebra = n => ehP(n) && temQuebraDOM(n);
const semNada = n => ehP(n) && pVazioDOM(n);
const soQuebra = n => comQuebra(n) && semNada(n); /* dá para descartar inteiro */
const vazio = n => semNada(n) && !comQuebra(n); /* linha em branco pura */
const morrer = [];
const marcado = n => morrer.indexOf(n)>=0;
const matar = n => { if(!marcado(n)) morrer.push(n); };
const vivos = () => lista.filter(n=>!marcado(n));
/* linhas em branco encostadas numa quebra não fazem falta */
for(let i=0;i=0 && vazio(lista[j]);j--) matar(lista[j]);
for(let j=i+1;j, e o texto continua onde está */
{
const v = vivos();
for(let i=1;i=0 && (vazio(v[i])||soQuebra(v[i]));i--) matar(v[i]);
}
morrer.forEach(n=>{ if(n.parentNode) n.parentNode.removeChild(n); });
repararCelulas(doc);
const saida = xmlEscrever(doc);
/* último confere: XML que não abre aqui também não abriria no Word */
return xmlLer(saida) ? saida : xml;
}
/* Cabeçalhos e rodapés não têm corpo nem quebra de página: ali basta tirar
as marcas e garantir que nenhuma célula fique vazia. */
function limparParteDOM(xml){
const doc = xmlLer(xml);
if(!doc) return xml.split(MARCA_VAZIA).join('');
const ts = doc.getElementsByTagNameNS(NS_W,'t');
for(let i=0;i=0) ts[i].textContent = v.split(MARCA_VAZIA).join('');
}
repararCelulas(doc);
const saida = xmlEscrever(doc);
return xmlLer(saida) ? saida : xml.split(MARCA_VAZIA).join('');
}
/* Junta os documentos numa peça só: o primeiro é a base (seção, estilos,
cabeçalho) e os demais entram depois de uma quebra de página, com as
imagens e vínculos renumerados para não colidirem. */
async function docxUnir(arquivos, campos){
if(!arquivos.length) throw new Error('Nenhum modelo para gerar.');
const base = await zipLer(arquivos[0].dados);
let baseXml = _lerTxt(base.get('word/document.xml'));
let baseRels = base.has('word/_rels/document.xml.rels')
? _lerTxt(base.get('word/_rels/document.xml.rels'))
: '';
let tipos = _lerTxt(base.get('[Content_Types].xml'));
let seqRel = 9000, seqMidia = 0;
for(let i=1;i]*\/>/g, tag=>{
const id=(tag.match(/Id="([^"]+)"/)||[])[1];
const tipo=(tag.match(/Type="([^"]+)"/)||[])[1]||'';
const alvo=(tag.match(/Target="([^"]+)"/)||[])[1]||'';
const externo=/TargetMode="External"/.test(tag);
if(!id) return tag;
const novoId='rId'+(++seqRel);
mapaId[id]=novoId;
let novoAlvo=alvo;
if(!externo && /^media\//.test(alvo)){
const orig='word/'+alvo;
if(z.has(orig)){
const ext=(alvo.split('.').pop()||'bin').toLowerCase();
novoAlvo='media/juncao'+(++seqMidia)+'.'+ext;
base.set('word/'+novoAlvo, z.get(orig));
if(!new RegExp('Extension="'+ext+'"','i').test(tipos)){
tipos = tipos.replace('');
return tag;
});
xml = xml.replace(/(r:(?:id|embed|link)=")([^"]+)(")/g, (m,a,id,c)=> a+(mapaId[id]||id)+c);
if(novos.length) baseRels = baseRels.replace('', novos.join('')+'');
baseXml = juntarNamespaces(baseXml, xml);
const corpo = corpoDe(xml);
if(!corpo.trim()) continue; /* modelo sem conteúdo não vira página */
const pos = baseXml.lastIndexOf('');
const secao = (baseXml.slice(0,pos).match(/$/)||[''])[0];
const antes = secao ? baseXml.slice(0, pos-secao.length) : baseXml.slice(0,pos);
/* a quebra sempre entra; as sobras em branco saem depois, na limpeza */
baseXml = antes + QUEBRA + corpo + secao + '' + baseXml.slice(pos+9);
}
/* marcadores: no corpo e também em cabeçalhos e rodapés */
const antesDaLimpeza = baseXml;
baseXml = limparMarcadoresRestantes(trocarMarcadores(baseXml, campos));
/* só agora dá para saber o que ficou vazio: marcador que saiu em branco
também deixa linha e folha em branco para trás */
baseXml = limparDocumentoDOM(baseXml);
/* Nada sai daqui sem passar por uma leitura completa do XML. Um documento
que não abre aqui é o mesmo que o Word recusaria com "erro ao tentar
abrir o arquivo" — melhor avisar do que entregar arquivo quebrado. */
if(!xmlLer(baseXml)){
const socorro = limparDocumentoDOM(antesDaLimpeza.split(MARCA_VAZIA).join(''));
if(!xmlLer(socorro))
throw new Error('um dos modelos tem uma estrutura que não consegui juntar — '+
'abra-o no Word e salve novamente como .docx');
baseXml = socorro;
}
base.set('word/document.xml', _gravTxt(baseXml));
base.set('word/_rels/document.xml.rels', _gravTxt(baseRels));
base.set('[Content_Types].xml', _gravTxt(tipos));
for(const nome of [...base.keys()]){
if(/^word\/(header|footer)\d*\.xml$/.test(nome)){
base.set(nome, _gravTxt(limparParteDOM(
limparMarcadoresRestantes(trocarMarcadores(_lerTxt(base.get(nome)), campos)))));
}
}
return await zipEscrever(base);
}
/* Relê o .docx recém-montado como o Word faria: abre o pacote, confere as
peças obrigatórias e lê o XML inteiro. Qualquer defeito vira uma mensagem
clara em vez de um arquivo que não abre. */
async function conferirDocx(bytes){
let z;
try{ z = await zipLer(bytes.buffer ? bytes.buffer : bytes); }
catch(e){ throw new Error('o arquivo montado não pôde ser reaberto'); }
if(!z.has('word/document.xml') || !z.has('[Content_Types].xml'))
throw new Error('faltou uma peça obrigatória do Word no arquivo montado');
const doc = xmlLer(_lerTxt(z.get('word/document.xml')));
if(!doc) throw new Error('o texto do documento saiu com uma estrutura que o Word recusaria');
const tcs = doc.getElementsByTagNameNS(NS_W,'tc');
for(let i=0;i'+
linhas.map(l=>'').join('')+
'';
}
const ED_ICONES = {
esquerda: edIc([[2,3,12],[2,6.5,7],[2,10,12],[2,13.5,7]]),
centro: edIc([[2,3,12],[4.5,6.5,7],[2,10,12],[4.5,13.5,7]]),
direita: edIc([[2,3,12],[7,6.5,7],[2,10,12],[7,13.5,7]]),
justificado: edIc([[2,3,12],[2,6.5,12],[2,10,12],[2,13.5,12]])
};
/* opts: {compacto:true} deixa a barra em uma linha só, para caixas menores;
{eventos:'…'} acrescenta atributos ao campo (usado pelas menções). */
function edHTML(id, valor, ph, opts){
const o = opts || {};
const v = edValorInicial(valor);
const bt = (cmd, titulo, rotulo, cls) =>
'';
const sep = '';
const barra =
''+
''+
(o.compacto ? '' :
'')+
sep+
bt('bold','Negrito (Ctrl+B)','N')+
bt('italic','Itálico (Ctrl+I)','I')+
bt('underline','Sublinhado (Ctrl+U)','S')+
bt('tachado','Tachado','T')+
(o.compacto ? '' : bt('sobrescrito','Sobrescrito','x²')+bt('subscrito','Subscrito','x₂'))+
sep+
''+
''+
sep+
bt('lista','Lista com marcadores','•—')+
bt('numerada','Lista numerada','1.')+
(o.compacto ? '' :
bt('esquerda','Alinhar à esquerda',ED_ICONES.esquerda)+
bt('centro','Centralizar',ED_ICONES.centro)+
bt('direita','Alinhar à direita',ED_ICONES.direita)+
bt('justificado','Justificar',ED_ICONES.justificado)+
sep+
bt('recuar','Aumentar recuo','→|')+
bt('desrecuar','Diminuir recuo','|←')+
bt('linha','Linha horizontal','—')+
sep)+
bt('link','Inserir ou trocar link','🔗')+
(o.compacto ? '' : bt('tirarlink','Remover link','🔗')) +
sep+
bt('desfazer','Desfazer (Ctrl+Z)','↶')+
bt('refazer','Refazer (Ctrl+Shift+Z)','↷')+
bt('limpar','Remover formatação','✕ formato','lp');
return '
'+
'
'+barra+'
'+
'
'+v+'
'+
'
';
}
/* Atalhos de teclado do Word que o navegador não cobre sozinho. */
function edTecla(ev, id){
if(!(ev.ctrlKey || ev.metaKey)) return;
const k = String(ev.key||'').toLowerCase();
if(k==='b'){ ev.preventDefault(); edCmd(id,'bold'); }
else if(k==='i'){ ev.preventDefault(); edCmd(id,'italic'); }
else if(k==='u'){ ev.preventDefault(); edCmd(id,'underline'); }
}
/* Textos antigos vieram sem marcação: viram parágrafos simples. */
function edValorInicial(valor){
const v = String(valor==null?'':valor);
if(/<(p|div|br|span|b|i|u|ul|ol|li|font)\b/i.test(v)) return v;
if(!v.trim()) return '';
return v.split(/\r?\n/).map(l=>'
'+esc(l||'')+(l?'':' ')+'
').join('');
}
function edFoco(el){ el.classList.toggle('vazio', !el.textContent.trim() && !el.querySelector('img')); }
function edCmd(id, cmd, val){
const el = $(id); if(!el || el.getAttribute('contenteditable')!=='true') return;
el.focus();
try{ document.execCommand('styleWithCSS', false, true); }catch(e){}
if(cmd==='bold' || cmd==='italic' || cmd==='underline'){ document.execCommand(cmd, false, null); }
else if(cmd==='tachado'){ document.execCommand('strikeThrough', false, null); }
else if(cmd==='sobrescrito'){ document.execCommand('superscript', false, null); }
else if(cmd==='subscrito'){ document.execCommand('subscript', false, null); }
else if(cmd==='fonte'){ document.execCommand('fontName', false, val); }
else if(cmd==='tam'){ edTamanho(el, val); }
else if(cmd==='estilo'){ document.execCommand('formatBlock', false, val==='p'?'div':val); }
else if(cmd==='cor'){ document.execCommand('foreColor', false, val); }
else if(cmd==='marca'){
if(!document.execCommand('hiliteColor', false, val)) document.execCommand('backColor', false, val);
}
else if(cmd==='lista'){ document.execCommand('insertUnorderedList', false, null); }
else if(cmd==='numerada'){ document.execCommand('insertOrderedList', false, null); }
else if(cmd==='esquerda'){ document.execCommand('justifyLeft', false, null); }
else if(cmd==='centro'){ document.execCommand('justifyCenter', false, null); }
else if(cmd==='direita'){ document.execCommand('justifyRight', false, null); }
else if(cmd==='justificado'){ document.execCommand('justifyFull', false, null); }
else if(cmd==='recuar'){ document.execCommand('indent', false, null); }
else if(cmd==='desrecuar'){ document.execCommand('outdent', false, null); }
else if(cmd==='linha'){ document.execCommand('insertHorizontalRule', false, null); }
else if(cmd==='desfazer'){ document.execCommand('undo', false, null); }
else if(cmd==='refazer'){ document.execCommand('redo', false, null); }
else if(cmd==='link'){ edLink(el); }
else if(cmd==='tirarlink'){ document.execCommand('unlink', false, null); }
else if(cmd==='limpar'){ document.execCommand('removeFormat', false, null); document.execCommand('unlink', false, null); }
edFoco(el);
}
/* Pede o endereço e aplica ao trecho selecionado. */
function edLink(el){
const sel = getSelection();
let atual = '';
try{
let n = sel.anchorNode;
while(n && n!==el){ if(n.tagName==='A'){ atual = n.getAttribute('href')||''; break; } n = n.parentNode; }
}catch(e){}
const url = prompt('Endereço do link (começando com https://):', atual || 'https://');
if(url === null) return;
const v = String(url).trim();
if(!v){ document.execCommand('unlink', false, null); return; }
if(!/^(https?:|mailto:|tel:)/i.test(v)){ toast('O link precisa começar com https://, mailto: ou tel:.'); return; }
if(sel.isCollapsed){
document.execCommand('insertHTML', false,
''+esc(v)+' ');
} else {
document.execCommand('createLink', false, v);
}
}
/* O navegador só entende os sete tamanhos antigos; convertemos o maior
deles em um tamanho em pontos de verdade. */
function edTamanho(el, pt){
/* com styleWithCSS ligado o navegador devolveria "xxx-large"; desligamos só
aqui para receber e trocar por um tamanho em pontos */
try{ document.execCommand('styleWithCSS', false, false); }catch(e){}
document.execCommand('fontSize', false, '7');
try{ document.execCommand('styleWithCSS', false, true); }catch(e){}
const novos=[];
el.querySelectorAll('font[size="7"]').forEach(f=>{
const s = document.createElement('span');
s.style.fontSize = pt+'pt';
while(f.firstChild) s.appendChild(f.firstChild);
f.replaceWith(s); novos.push(s);
});
/* alguns navegadores insistem no tamanho por palavra-chave */
el.querySelectorAll('span[style*="xxx-large"],span[style*="xx-large"]').forEach(s=>{
s.style.fontSize = pt+'pt'; novos.push(s);
});
/* trocar os nós desfaz a seleção; devolvemos o trecho selecionado para que
o próximo botão da barra continue valendo para o mesmo texto */
if(novos.length){
try{
const r=document.createRange();
r.setStartBefore(novos[0]); r.setEndAfter(novos[novos.length-1]);
const s=getSelection(); s.removeAllRanges(); s.addRange(r);
}catch(e){}
}
}
function edLimparHTML(html){
const d = document.createElement('div');
d.innerHTML = String(html==null?'':html);
const ok = ['font-family','font-size','color','background-color','text-decoration',
'text-decoration-line','font-weight','font-style','text-align','margin-left',
'padding-left','vertical-align'];
(function anda(n){
[...n.childNodes].forEach(c=>{
if(c.nodeType===3) return;
if(c.nodeType!==1){ c.remove(); return; }
if(!ED_TAGS[c.tagName]){
const f=document.createDocumentFragment();
while(c.firstChild) f.appendChild(c.firstChild);
c.replaceWith(f); return;
}
[...c.attributes].forEach(at=>{
const nome = at.name.toLowerCase();
if(nome==='style'){
const bons=[];
String(at.value).split(';').forEach(pe=>{
const k=(pe.split(':')[0]||'').trim().toLowerCase();
if(ok.indexOf(k)>=0 && !/url\(|expression|javascript:/i.test(pe)) bons.push(pe.trim());
});
if(bons.length) c.setAttribute('style', bons.join('; ')); else c.removeAttribute('style');
} else if(c.tagName==='FONT' && (nome==='color'||nome==='face'||nome==='size')){ /* mantém */ }
else if(c.tagName==='A' && nome==='href'){
/* só links de verdade: nada de javascript: nem data: */
if(!/^(https?:|mailto:|tel:)/i.test(String(at.value).trim())) c.removeAttribute('href');
else { c.setAttribute('target','_blank'); c.setAttribute('rel','noopener noreferrer'); }
}
else if(c.tagName==='A' && (nome==='target'||nome==='rel')){ /* mantém */ }
else if(nome==='align'){ /* mantém o alinhamento antigo */ }
else c.removeAttribute(at.name);
});
anda(c);
});
})(d);
return d.innerHTML;
}
function edTexto(html){
const d=document.createElement('div'); d.innerHTML=String(html==null?'':html);
return (d.textContent||'').replace(/\s+/g,' ').trim();
}
function edValor(id){ const el=$(id); return el ? edLimparHTML(el.innerHTML) : ''; }
/* ---------- do HTML para o Word ---------- */
function edHex(cor){
const c=String(cor||'').trim();
let m=/^#?([0-9a-f]{6})$/i.exec(c); if(m) return m[1].toUpperCase();
m=/^#?([0-9a-f]{3})$/i.exec(c);
if(m) return m[1].split('').map(x=>x+x).join('').toUpperCase();
m=/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(c);
if(m) return [1,2,3].map(i=>(+m[i]).toString(16).padStart(2,'0')).join('').toUpperCase();
return null;
}
/* Percorre o texto formatado e devolve uma lista de parágrafos, cada um com
os seus pedaços e o estilo de cada pedaço. */
function edParaWord(html, base){
const d=document.createElement('div'); d.innerHTML=edValorInicial(html);
const paras=[]; let atual=[];
const fecha=()=>{ if(atual.length){ paras.push(atual); atual=[]; } else paras.push([]); };
const BLOCO={DIV:1,P:1,LI:1,H1:1,H2:1,H3:1,BLOCKQUOTE:1};
const ALINHA={left:'left',center:'center',right:'right',justify:'both'};
function estilo(el, h){
const e=Object.assign({}, h), t=el.tagName, st=el.style||{};
if(t==='B'||t==='STRONG') e.b=true;
if(t==='I'||t==='EM') e.i=true;
if(t==='U') e.u=true;
if(t==='S'||t==='STRIKE') e.tachado=true;
if(t==='SUP') e.sup=true;
if(t==='SUB') e.sub=true;
if(t==='A') e.u=e.u||true, e.cor=e.cor||'1E3A5F';
if(t==='H1'){ e.b=true; e.tam=e.tam||16; }
if(t==='H2'){ e.b=true; e.tam=e.tam||14; }
if(t==='H3'){ e.b=true; e.tam=e.tam||13; }
if(String(st.textDecoration||'').indexOf('line-through')>=0 ||
String(st.textDecorationLine||'').indexOf('line-through')>=0) e.tachado=true;
if(st.verticalAlign==='super') e.sup=true;
if(st.verticalAlign==='sub') e.sub=true;
if(st.fontWeight==='bold' || parseInt(st.fontWeight,10)>=600) e.b=true;
if(st.fontStyle==='italic') e.i=true;
if(String(st.textDecoration||'').indexOf('underline')>=0 ||
String(st.textDecorationLine||'').indexOf('underline')>=0) e.u=true;
if(st.fontFamily) e.fonte=String(st.fontFamily).replace(/["']/g,'').split(',')[0].trim();
if(st.fontSize){ const m=/^([\d.]+)\s*(pt|px)$/.exec(st.fontSize);
if(m) e.tam = m[2]==='pt' ? +m[1] : +m[1]*0.75; }
if(st.color){ const h2=edHex(st.color); if(h2) e.cor=h2; }
if(st.backgroundColor){ const h2=edHex(st.backgroundColor); if(h2 && h2!=='FFFFFF') e.fundo=h2; }
if(el.getAttribute){
const c=el.getAttribute('color'); if(c){ const h2=edHex(c); if(h2) e.cor=h2; }
const f=el.getAttribute('face'); if(f) e.fonte=String(f).split(',')[0].trim();
}
return e;
}
(function anda(n, h){
[...n.childNodes].forEach(c=>{
if(c.nodeType===3){
const txt=c.nodeValue;
if(txt) atual.push({t:txt, e:h});
return;
}
if(c.nodeType!==1) return;
if(c.tagName==='BR'){ fecha(); return; }
if(c.tagName==='HR'){ if(atual.length) fecha(); paras.push([{t:'', e:{regua:true}}]); return; }
if(BLOCO[c.tagName]){
if(atual.length) fecha();
let sub = h;
if(c.tagName==='LI'){
const numerada = c.parentNode && c.parentNode.tagName==='OL';
sub = Object.assign({}, h, {lista:true, numerada:numerada});
} else if(c.tagName==='BLOCKQUOTE'){
sub = Object.assign({}, h, {citacao:true, i:true});
} else sub = Object.assign({}, h);
/* alinhamento vale para o parágrafo inteiro */
const al = (c.style && c.style.textAlign) || (c.getAttribute && c.getAttribute('align')) || '';
if(ALINHA[al]) sub.jc = ALINHA[al];
anda(c, c.tagName==='H1'||c.tagName==='H2'||c.tagName==='H3' ? estilo(c, sub) : sub);
fecha();
return;
}
anda(c, estilo(c, h));
});
})(d, Object.assign({fonte:'Arial', tam:12}, base||{}));
if(atual.length) fecha();
return paras.filter((p,i,arr)=>p.length || (i>0 && ix.compTipo);
var ATA_ETIQUETAS = ['Marketing','Campanhas de tráfego pago','Prospecção de clientes',
'Melhorias internas','Processos e rotinas','Metas e resultados','Financeiro',
'Equipe e pessoas','Tecnologia e sistemas','Clientes e atendimento',
'Parcerias','Treinamento','Planejamento'];
function ataTipoInfo(k){ return ATA_TIPOS.find(x=>x.k===k) || ATA_TIPOS[1]; }
function ataTipoDoCompromisso(t){
const a = ATA_TIPOS.find(x=>x.compTipo===t);
return a ? a.k : null;
}
/* Gestão é restrita por função; os demais tipos, por participação. */
function podeVerTipoAta(k){
if(k!=='gestao') return true;
const u = eu(); if(!u) return false;
return u.funcao==='Administração' || u.funcao==='Advogado(a) sócio(a)';
}
function podeVerAta(a){
if(!a) return false;
if(!podeVerTipoAta(a.tipo)) return false;
const u = S.usuario;
return a.autorId===u || (a.participantes||[]).indexOf(u)>=0;
}
function podeEditarAta(a){
if(!podeVerAta(a)) return false;
return a.autorId===S.usuario || pode('editar_todos') || ehAdmin();
}
function atas(){ return Array.isArray(S.atas) ? S.atas : (S.atas=[]); }
function ataPorId(id){ return atas().find(a=>a.id===+id) || null; }
function ataNumero(ano){
const n = atas().filter(a=>String(a.data||'').slice(0,4)===String(ano)).length + 1;
return String(n).padStart(3,'0')+'/'+ano;
}
/* Cria (ou atualiza) a ata ligada a um compromisso de reunião. */
function ataDoCompromisso(c){
if(!c) return null;
const k = ataTipoDoCompromisso(c.tipo); if(!k) return null;
let a = atas().find(x=>x.compId===c.id);
const gente = [c.donoId].concat(c.participantes||[]).filter((v,i,arr)=>v!=null && arr.indexOf(v)===i);
if(!a){
a = {id:novoId('ata'), numero:ataNumero(String(c.data||'').slice(0,4)||new Date().getFullYear()),
tipo:k, titulo:c.titulo||'Reunião', data:c.data||'', hora:c.hora||'', horaFim:c.horaFim||'',
local:c.local||'', compId:c.id, participantes:gente, autorId:c.donoId||S.usuario,
criadoPor:c.criadoPor||(eu()&&eu().nome)||'—', criadoEm:agoraISO(),
tags:[], pauta:'', texto:'', encaminhamentos:'', editada:false};
atas().push(a);
return a;
}
/* enquanto ninguém escreveu a ata, ela acompanha o compromisso */
if(!a.editada){
a.titulo=c.titulo||a.titulo; a.data=c.data||a.data; a.hora=c.hora||'';
a.horaFim=c.horaFim||''; a.local=c.local||''; a.tipo=k; a.participantes=gente;
}
return a;
}
function ataRemoverDoCompromisso(compId){
const i = atas().findIndex(a=>a.compId===compId);
if(i<0) return false;
if(atas()[i].editada) return false; /* já tem conteúdo: não se apaga sozinha */
atas().splice(i,1);
return true;
}
/* ---------------------------- painel ---------------------------- */
function ataPeriodo(){
if(!UI.ataMes){ const h=new Date(); UI.ataMes = h.getMonth()+1; UI.ataAno = h.getFullYear(); }
return {mes:UI.ataMes, ano:UI.ataAno};
}
function ataAnosDisponiveis(){
const s = new Set(atas().filter(podeVerAta).map(a=>+String(a.data||'').slice(0,4)).filter(x=>x));
s.add(new Date().getFullYear());
return [...s].sort((a,b)=>b-a);
}
function atasVisiveis(){
const p = ataPeriodo();
return atas().filter(podeVerAta).filter(a=>{
if(UI.ataMes===0) return String(a.data||'').slice(0,4)===String(p.ano);
return String(a.data||'').slice(0,7) === p.ano+'-'+String(p.mes).padStart(2,'0');
}).filter(a=>{
const q=(UI.ataBusca||'').trim().toLowerCase();
if(!q) return true;
return (a.titulo+' '+(a.tags||[]).join(' ')+' '+edTexto(a.pauta)+' '+edTexto(a.texto)).toLowerCase().indexOf(q)>=0;
}).sort((a,b)=>String(b.data||'').localeCompare(String(a.data||'')) ||
String(b.hora||'').localeCompare(String(a.hora||'')));
}
function renderAtas(){
const el=$('pane-atas'); if(!el) return;
const p = ataPeriodo(), lista = atasVisiveis();
const tipos = ATA_TIPOS.filter(t=>podeVerTipoAta(t.k));
const MES = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'];
let h = '
Cada ata é lida apenas por quem esteve na reunião. As de Gestão ficam restritas a '+
'Administração e sócios — os demais perfis não veem sequer o bloco.
'+
'';
el.innerHTML = h;
renderAtasLista();
}
function renderAtasLista(){
const el=$('atas-lista'); if(!el) return;
const lista = atasVisiveis();
const tipos = ATA_TIPOS.filter(t=>podeVerTipoAta(t.k));
el.innerHTML = tipos.map(t=>{
const dele = lista.filter(a=>a.tipo===t.k);
return '
');
}).join('');
}
/* ---------------------------- janela da ata ---------------------------- */
function novaAta(){
const hoje = iso(hoje0());
const a = {id:novoId('ata'), numero:ataNumero(hoje.slice(0,4)), tipo:podeVerTipoAta('gestao')?'equipe':'equipe',
titulo:'', data:hoje, hora:'', horaFim:'', local:'', compId:null,
participantes:[S.usuario], autorId:S.usuario, criadoPor:(eu()&&eu().nome)||'—',
criadoEm:agoraISO(), tags:[], pauta:'', texto:'', encaminhamentos:'', editada:true};
atas().push(a); UI.ataNovaId = a.id; salvar();
abrirAta(a.id, true);
}
function abrirAta(id, nova){
const a = ataPorId(id); if(!a) return;
if(!podeVerAta(a)){ toast('Esta ata é restrita a quem participou da reunião.'); return; }
UI.ataEdit = id;
const editavel = podeEditarAta(a);
const tipos = ATA_TIPOS.filter(t=>podeVerTipoAta(t.k) || t.k===a.tipo);
$('at-kind').textContent = 'Ata '+(a.numero||'')+' · '+ataTipoInfo(a.tipo).n;
$('at-titulo').textContent = a.titulo || 'Nova ata';
$('at-corpo').innerHTML =
'
'+
'
'+
'
'+
''+
''+
''+
''+
''+
'
'+
'
Só quem está nesta lista enxerga a ata.
'+
'
'+
'
'+
edHTML('at-pauta', a.pauta||'', 'Os pontos que a reunião veio tratar.')+'
'+
'
'+
edHTML('at-texto', a.texto||'', 'O que foi discutido, o que ficou decidido e por quê.')+'
'+
'
'+
'
'+
'
'+
(a.compId ? '
Esta ata nasceu de um compromisso da agenda. '+
'
' : '')+
'
Criada por '+esc(a.criadoPor||'—')+' em '+esc(fmtDH(a.criadoEm))+
(a.atualizadoEm? ' · última alteração por '+esc(a.atualizadoPor||'—')+' em '+esc(fmtDH(a.atualizadoEm)) : '')+'
';
$('at-part-wrap').innerHTML = msHTML('at-part-sel','Digite o nome de quem participou…');
msInit('at-part-sel', pessoasCampo(), a.participantes||[]);
msEuAtivar('at-part-sel','Inclui você entre os presentes.');
renderAtaTags(a.tags||[]);
$('at-del').style.display = editavel ? '' : 'none';
$('at-salvar').style.display = editavel ? '' : 'none';
document.querySelectorAll('#ov-ata input,#ov-ata select,#ov-ata textarea').forEach(el=>{ el.disabled = !editavel; });
document.querySelectorAll('#ov-ata .ed-area').forEach(el=>{
el.setAttribute('contenteditable', editavel?'true':'false'); edFoco(el); });
document.querySelectorAll('#ov-ata .ed-bar').forEach(el=>{ el.style.display = editavel?'':'none'; });
if(!editavel) toast('Somente leitura — esta ata é de '+((membro(a.autorId)||{}).nome||'outro integrante')+'.');
abrir('ov-ata');
if(nova) setTimeout(()=>{ const e=$('at-tit'); if(e) e.focus(); }, 80);
}
function renderAtaTags(sel){
UI.ataTags = (sel||[]).slice();
const el=$('at-tags'); if(!el) return;
const todas = ATA_ETIQUETAS.concat(UI.ataTags.filter(t=>ATA_ETIQUETAS.indexOf(t)<0));
el.innerHTML = '
'+
todas.map(t=>'').join('')+
'
';
}
function alternarAtaTag(t){
const i = UI.ataTags.indexOf(t);
if(i>=0) UI.ataTags.splice(i,1); else UI.ataTags.push(t);
renderAtaTags(UI.ataTags);
}
function novaAtaTag(){
const t = prompt('Nome da etiqueta:');
if(!t || !t.trim()) return;
const v = t.trim();
if(UI.ataTags.indexOf(v)<0) UI.ataTags.push(v);
renderAtaTags(UI.ataTags);
}
function salvarAta(){
const a = ataPorId(UI.ataEdit); if(!a) return;
if(!podeEditarAta(a)) return;
const tit = ($('at-tit').value||'').trim();
const htmlTexto = edValor('at-texto');
const txt = edTexto(htmlTexto);
const err=[];
const marca=(id,ok)=>{ const f=document.querySelector('#ov-ata [data-for="'+id+'"]')||$(id).closest('.f');
if(f) f.classList.toggle('err', !ok); };
marca('at-tit', !!tit); if(!tit) err.push('Título');
marca('at-texto', !!txt); if(!txt) err.push('Registro da reunião');
if(err.length){ const b=$('at-err'); b.innerHTML='Revise: '+err.map(esc).join(', ')+'.'; b.classList.add('on'); return; }
$('at-err').classList.remove('on');
const antes = a.tipo;
a.titulo=tit; a.tipo=$('at-tipo').value; a.data=$('at-data').value||a.data;
a.hora=$('at-h1').value||''; a.horaFim=$('at-h2').value||'';
a.local=($('at-local').value||'').trim();
a.participantes = msSel('at-part-sel');
if(a.participantes.indexOf(a.autorId)<0) a.participantes.push(a.autorId);
a.tags = (UI.ataTags||[]).slice();
a.pauta=edValor('at-pauta');
a.texto=htmlTexto;
a.encaminhamentos=($('at-enc').value||'').trim();
a.editada = true; UI.ataNovaId = null;
a.atualizadoEm = agoraISO(); a.atualizadoPor = (eu()&&eu().nome)||'—';
salvar(); fechar('ov-ata'); redesenhar();
toast('Ata '+(a.numero||'')+' salva.');
}
async function excluirAta(){
const a = ataPorId(UI.ataEdit); if(!a) return;
if(!podeEditarAta(a)) return;
if(!await confirmar('Excluir a ata "'+(a.titulo||'sem título')+'"?\n\nO registro é apagado para todos os participantes e não há como desfazer.')) return;
const i = atas().findIndex(x=>x.id===a.id);
if(i>=0) atas().splice(i,1);
salvar(); fechar('ov-ata'); redesenhar();
toast('Ata excluída.');
}
/* ---------------------------- Word ---------------------------- */
function ataParagrafos(a){
const P = [];
const esq = s => escXml(String(s==null?'':s));
const par = (txt, opc) => {
opc = opc||{};
const linhas = String(txt==null?'':txt).split(/\r?\n/);
return linhas.map(l =>
''+
(opc.estilo?'':'')+
''+
(opc.centro?'':'')+
''+
''+
(opc.negrito?'':'')+
(opc.tam?'':'')+
(opc.cor?'':'')+
(opc.caixa?'':'')+
''+esq(l)+'');
};
P.push(...par(nomeEscritorio(), {negrito:true, centro:true, tam:20, cor:'12283F', caixa:true, depois:0}));
P.push(...par(ataTipoInfo(a.tipo).n.toUpperCase()+' · ATA Nº '+(a.numero||''), {centro:true, tam:18, cor:'8A6D1F', depois:240}));
P.push(...par(a.titulo||'Reunião', {negrito:true, centro:true, tam:28, depois:240}));
const cab = [];
cab.push('Data: '+fmtD(a.data)+(a.hora? ' Horário: '+a.hora+(a.horaFim?' às '+a.horaFim:'') : ''));
if(a.local) cab.push('Local: '+a.local);
const gente = (a.participantes||[]).map(id=>{ const m=membro(id); return m? m.nome+' — '+m.funcao : null; }).filter(Boolean);
cab.push('Participantes: '+(gente.length? gente.join('; ') : '—'));
if((a.tags||[]).length) cab.push('Etiquetas: '+a.tags.join(', '));
P.push(...par(cab.join('\n'), {tam:20, depois:200}));
/* Pauta e Registro vêm formatados da tela: cada pedaço de texto vira um
trecho do Word com a sua fonte, tamanho, cor, grifo e estilo. */
const parRico = (pedacos) => {
if(!pedacos.length) return '';
const e0 = pedacos[0].e || {};
if(e0.regua) return ''+
'';
return ''+
(e0.lista ? '' : '')+
(e0.citacao ? '' : '')+
''+
''+
pedacos.map(x=>{
const e=x.e||{};
return ''+
(e.fonte?'':'')+
(e.b?'':'')+(e.i?'':'')+(e.u?'':'')+
(e.tachado?'':'')+
(e.sup?'':'')+
(e.sub?'':'')+
(e.cor?'':'')+
(e.fundo?'':'')+
(e.tam?'':'')+
''+esq(x.t)+'';
}).join('')+'';
};
const secaoRica = (titulo, html) => {
if(!edTexto(html)) return;
P.push(...par(titulo, {negrito:true, tam:22, cor:'12283F', antes:240, depois:80}));
edParaWord(html).forEach(pedacos=>P.push(parRico(pedacos)));
};
const secao = (titulo, corpo) => {
if(!String(corpo||'').trim()) return;
P.push(...par(titulo, {negrito:true, tam:22, cor:'12283F', antes:240, depois:80}));
P.push(...par(corpo, {tam:22}));
};
secaoRica('PAUTA', a.pauta);
secaoRica('REGISTRO DA REUNIÃO', a.texto);
secao('ENCAMINHAMENTOS', a.encaminhamentos);
P.push(...par('\n'+cidadeEscritorio()+dataPorExtenso(a.data),
{tam:20, centro:true, antes:400, depois:320}));
gente.forEach(g=>{
P.push(...par('__________________________________________', {centro:true, tam:20, antes:200, depois:0}));
P.push(...par(g, {centro:true, tam:18, depois:160}));
});
return P.join('');
}
/* A linha de fecho da ata usa a cidade do escritório, quando ela está no
cadastro. Sem endereço cadastrado, fica só a data. */
function cidadeEscritorio(){
const end = String((escritorio()||{}).endereco||'');
if(!end.trim()) return '';
const partes = end.split(/[,–—]/).map(s=>s.trim()).filter(Boolean);
for(let i=partes.length-1;i>=0;i--){
const p = partes[i].replace(/\/\s*[A-Z]{2}\s*$/,'').replace(/CEP.*/i,'').trim();
if(p && !/^\d{5}-?\d{3}$/.test(p) && !/^\d+$/.test(p) && p.length>2) return p+', ';
}
return '';
}
function dataPorExtenso(iso){
const M=['janeiro','fevereiro','março','abril','maio','junho','julho','agosto','setembro','outubro','novembro','dezembro'];
const d = pd(iso) || new Date();
return d.getDate()+' de '+M[d.getMonth()]+' de '+d.getFullYear();
}
async function baixarAtaDocx(){
const a = ataPorId(UI.ataEdit); if(!a) return;
if(!podeVerAta(a)) return;
const doc =
''+
''+
''+ataParagrafos(a)+
''+
''+
'';
const tipos =
''+
''+
''+
''+
''+
''+
''+
'';
const rels =
''+
''+
''+
'';
const relsDoc =
''+
''+
''+
''+
'';
const estilos =
''+
''+
''+
''+
''+
''+
'';
const numeracao =
''+
''+
''+
''+
''+
''+
''+
''+
''+
''+
''+
''+
''+
'';
const mapa = new Map([
['[Content_Types].xml', _gravTxt(tipos)],
['_rels/.rels', _gravTxt(rels)],
['word/_rels/document.xml.rels', _gravTxt(relsDoc)],
['word/styles.xml', _gravTxt(estilos)],
['word/numbering.xml', _gravTxt(numeracao)],
['word/document.xml', _gravTxt(doc)]
]);
try{
const zip = await zipEscrever(mapa);
const nome = 'ata-'+String(a.numero||'').replace('/','-')+'-'+
(a.titulo||'reuniao').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')
.replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,40)+'.docx';
const ok = await baixar(nome, zip, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
if(ok) toast('Ata gerada em Word.');
}catch(e){ toast('Não consegui gerar o arquivo Word desta ata.'); }
}
const KEY_MODELOS = 'gescrit_modelos_v1';
/* As ações ficam agrupadas por área macro: escolher a área primeiro deixa a
lista curta e legível. A busca, quando usada, procura em todas. */
const AREAS_DOC = [
{k:'prev', n:'Previdenciário'},
{k:'trab', n:'Trabalhista'},
{k:'suc', n:'Sucessões'},
{k:'ger', n:'Genérico / Outros'}
];
function nomeArea(k){ const a=AREAS_DOC.find(x=>x.k===k); return a?a.n:'Genérico / Outros'; }
const ACOES_DOC_PADRAO = [
['Aposentadoria por idade urbana','prev'],
['Aposentadoria por idade rural','prev'],
['Aposentadoria por tempo de contribuição','prev'],
['Aposentadoria da pessoa com deficiência','prev'],
['Auxílio por incapacidade temporária (auxílio-doença)','prev'],
['Aposentadoria por incapacidade permanente','prev'],
['Auxílio-acidente','prev'],
['BPC/LOAS — pessoa com deficiência','prev'],
['BPC/LOAS — idoso','prev'],
['Pensão por morte','prev'],
['Salário-maternidade','prev'],
['Revisão de benefício','prev'],
['Reclamatória trabalhista','trab'],
['Rescisão indireta','trab'],
['Reconhecimento de vínculo empregatício','trab'],
['Verbas rescisórias','trab'],
['Horas extras e adicionais','trab'],
['Acidente de trabalho e doença ocupacional','trab'],
['Inventário e partilha','suc'],
['Arrolamento sumário','suc'],
['Testamento e disposições de última vontade','suc'],
['Contrato de honorários genérico','ger'],
['Recibo financeiro','ger']
];
/* O recibo pede dois campos que nenhuma outra peça usa: o valor e o valor
escrito por extenso. A ação é reconhecida pelo nome, então um modelo
cadastrado como "Recibo de honorários" também ganha os campos. */
function ehRecibo(nome){ return /recibo/i.test(String(nome||'')); }
/* Número por extenso, em reais — o que vai na linha do recibo. */
function porExtenso(v){
const n = Math.round((+v || 0) * 100);
const reais = Math.floor(n / 100), centavos = n % 100;
const U = ['zero','um','dois','três','quatro','cinco','seis','sete','oito','nove','dez','onze','doze',
'treze','quatorze','quinze','dezesseis','dezessete','dezoito','dezenove'];
const D = ['','','vinte','trinta','quarenta','cinquenta','sessenta','setenta','oitenta','noventa'];
const C = ['','cento','duzentos','trezentos','quatrocentos','quinhentos','seiscentos','setecentos','oitocentos','novecentos'];
const ate999 = x => {
if(x===0) return '';
if(x===100) return 'cem';
const c=Math.floor(x/100), r=x%100, p=[];
if(c) p.push(C[c]);
if(r){
if(r<20) p.push(U[r]);
else { const d=Math.floor(r/10), u=r%10; p.push(D[d]+(u?' e '+U[u]:'')); }
}
return p.join(' e ');
};
const grupos = (x, sing, plur) => {
if(!x) return '';
if(x===1 && sing==='mil') return 'mil';
return ate999(x)+' '+(x===1?sing:plur);
};
const escrever = x => {
if(x===0) return 'zero';
const bi=Math.floor(x/1000000000), mi=Math.floor((x%1000000000)/1000000),
mil=Math.floor((x%1000000)/1000), res=x%1000;
const p=[];
if(bi) p.push(grupos(bi,'bilhão','bilhões'));
if(mi) p.push(grupos(mi,'milhão','milhões'));
if(mil) p.push(grupos(mil,'mil','mil'));
if(res) p.push(ate999(res));
if(p.length<2) return p.join('');
const ultimo = p.pop();
/* "mil e duzentos", "dois mil e cinquenta"; mas "mil, duzentos e trinta" */
const junta = (res && res<100) || (res % 100 === 0 && res>0);
return p.join(', ')+(junta?' e ':', ')+ultimo;
};
/* milhão e bilhão redondos pedem a preposição: "um milhão de reais" */
const redondo = reais >= 1000000 && reais % 1000000 === 0;
const partes=[];
if(reais) partes.push(escrever(reais)+(redondo?' de ':' ')+(reais===1?'real':'reais'));
if(centavos)partes.push(escrever(centavos)+' '+(centavos===1?'centavo':'centavos'));
if(!partes.length) return 'zero real';
return partes.join(' e ');
}
function reaisTexto(v){
return 'R$ '+(+v||0).toLocaleString('pt-BR',{minimumFractionDigits:2, maximumFractionDigits:2});
}
/* "1.234,56" ou "1234.56" viram 1234.56 */
function valorNumero(txt){
let t=String(txt==null?'':txt).replace(/[^\d,.-]/g,'');
if(t.indexOf(',')>=0) t=t.replace(/\./g,'').replace(',', '.');
const v=parseFloat(t);
return isNaN(v)?0:v;
}
/* Base guardada antes das áreas existirem: cada ação recebe a sua pela lista
padrão; o que não estiver lá fica em Genérico / Outros. */
function docsAreaPadrao(nome){
const n = String(nome||'').trim().toLowerCase();
const achado = ACOES_DOC_PADRAO.find(x=>x[0].toLowerCase()===n);
if(achado) return achado[1];
if(/previd|aposentad|auxílio|auxilio|bpc|loas|pensão por morte|salário-matern|salario-matern|benefício|beneficio/.test(n)) return 'prev';
if(/trabalh|rescis|vínculo|vinculo|verbas|horas extras|fgts|justa causa/.test(n)) return 'trab';
if(/inventário|inventario|partilha|arrolamento|testament|sucess|herança|heranca/.test(n)) return 'suc';
return 'ger';
}
const NACIONALIDADES = ['brasileiro','brasileira','argentino','argentina','boliviano','boliviana',
'haitiano','haitiana','italiano','italiana','paraguaio','paraguaia','peruano','peruana',
'português','portuguesa','uruguaio','uruguaia','venezuelano','venezuelana'];
const ESTADOS_CIVIS = {
m:['solteiro','casado','divorciado','separado de fato','em união estável','viúvo'],
f:['solteira','casada','divorciada','separada de fato','em união estável','viúva']
};
const MARCADORES = [
['NOME','Nome completo, como digitado'],
['QUALIFICACAO','Qualificação inteira, pronta para a abertura da peça'],
['NACIONALIDADE','brasileiro, brasileira…'],
['ESTADO_CIVIL','solteiro, casada, em união estável…'],
['PROFISSAO','Profissão'],
['CPF_CNPJ','CPF ou CNPJ já formatado'],
['CPF','Só quando for pessoa física'],
['CNPJ','Só quando for pessoa jurídica'],
['RG','Número do RG'],
['ORGAO_EMISSOR','Órgão emissor do RG'],
['RG_ORGAO','RG e órgão juntos: 1234567 SSP/SC'],
['ENDERECO','Endereço completo numa linha'],
['RUA','Logradouro'],['NUMERO','Número ou S/N'],['BAIRRO','Bairro'],
['CIDADE','Cidade'],['ESTADO','UF'],['CEP','CEP'],
['ACAO','Tipo de ação escolhido'],
['DATA','25 de agosto de 2026'],
['DATA_CURTA','25/08/2026'],
['LOCAL_DATA','Cidade, 25 de agosto de 2026'],
['ESCRITORIO','Nome do escritório'],
['CNPJ_ESCRITORIO','CNPJ do escritório'],
['OAB_ESCRITORIO','Inscrição da sociedade na OAB'],
['ENDERECO_ESCRITORIO','Endereço do escritório'],
['VALOR','Valor em reais — só no Recibo financeiro'],
['VALOR_EXTENSO','Valor por extenso — só no Recibo financeiro'],
['REP_NOME','Nome do representante'],
['REP_QUALIFICACAO','Qualificação inteira do representante'],
['REP_RELACAO','Vínculo: mãe, pai, tutora, curador, inventariante…'],
['REP_NACIONALIDADE','Nacionalidade do representante'],
['REP_ESTADO_CIVIL','Estado civil do representante'],
['REP_PROFISSAO','Profissão do representante'],
['REP_CPF_CNPJ','CPF ou CNPJ do representante, já formatado'],
['REP_RG','RG do representante'],
['REP_ORGAO_EMISSOR','Órgão emissor do RG do representante'],
['REP_RG_ORGAO','RG e órgão do representante, juntos'],
['REP_ENDERECO','Endereço do representante numa linha'],
['REP_RUA','Logradouro do representante'],
['REP_NUMERO','Número ou S/N do representante'],
['REP_BAIRRO','Bairro do representante'],
['REP_CIDADE','Cidade do representante'],
['REP_ESTADO','UF do representante'],
['REP_CEP','CEP do representante'],
['QUALIFICACAO_COMPLETA','Cliente e, havendo representante, a frase de representação já pronta'],
['REPRESENTACAO','Só a frase “neste ato representado por …”, vazia quando não há representante']
];
/* Vínculos mais comuns na área trabalhista e previdenciária. A lista é uma
sugestão: o campo aceita qualquer texto. */
const DOC_RELACOES = ['mãe','pai','avó','avô','tutor(a)','curador(a)','inventariante',
'representante legal','procurador(a)','responsável legal'];
/* =========================================================
MARCADORES PRÓPRIOS
Além dos marcadores de fábrica acima, a Administração cria os seus.
Cada um guarda três coisas: a CHAVE que vai no .docx ({{ASSIM}}), a
descrição do que ele significa — o "ao que se refere" — e a ORIGEM do
valor, que pode ser um campo do cliente, um dado do escritório, um
texto fixo ou um campo que aparece na hora de gerar.
========================================================= */
const MARC_ORIGENS = [
{k:'manual', n:'Preencher na hora', d:'Vira um campo a mais no formulário de gerar documentos. Use para o que muda a cada peça: número de protocolo, nome da parte contrária, valor da causa.'},
{k:'cliente', n:'Campo do cliente', d:'Puxa direto do cadastro do cliente escolhido no passo 2.'},
{k:'escritorio', n:'Dado do escritório', d:'Puxa de Configurações › Escritório.'},
{k:'fixo', n:'Texto fixo', d:'Sempre o mesmo texto, escrito aqui uma vez. Bom para cláusulas e fórmulas de praxe.'}
];
/* O que dá para puxar do cadastro do cliente. */
const MARC_CAMPOS_CLIENTE = [
['nome','Nome do cliente'], ['cpfCnpj','CPF ou CNPJ'], ['tel','Telefone'], ['email','E-mail'],
['area','Área do caso'], ['tipoAcao','Tipo(s) de ação'], ['origem','Origem do cliente'],
['numProcesso','Número do processo'], ['tribunal','Tribunal'], ['statusProcesso','Situação do processo'],
['dataContrato','Data do contrato'], ['linkDrive','Link da pasta no Drive'],
['repNome','Nome do representante'], ['repCpf','CPF do representante'], ['repRelacao','Relação com o representado'],
['senhaInss','Senha do Meu INSS'], ['obs','Observações do cadastro']
];
const MARC_CAMPOS_ESCRITORIO = [
['nome','Nome do escritório'], ['cnpj','CNPJ'], ['oab','Inscrição na OAB'], ['endereco','Endereço'],
['telefone','Telefone'], ['email','E-mail'], ['site','Site'], ['sigla','Sigla']
];
/* A chave sai do nome, no formato que o Word entende: MAIÚSCULAS_SEM_ACENTO. */
function marcChaveDe(txt){
return String(txt||'').normalize('NFD').replace(/[̀-ͯ]/g,'')
.toUpperCase().replace(/[^A-Z0-9]+/g,'_').replace(/^_|_$/g,'').slice(0,40);
}
function marcLista(){ if(!Array.isArray(DOCS.marcadores)) DOCS.marcadores = []; return DOCS.marcadores; }
function marcPadraoTem(ch){ return MARCADORES.some(m=>m[0]===ch); }
/* O valor de cada marcador próprio, no momento da geração. */
function marcValores(d, cli){
const out = {};
marcLista().forEach(m=>{
let v = '';
if(m.origem==='fixo') v = m.fixo||'';
else if(m.origem==='escritorio'){ const e=escritorio(); v = e[m.campo]!=null ? e[m.campo] : ''; }
else if(m.origem==='cliente'){
if(cli){
v = cli[m.campo]!=null ? cli[m.campo] : '';
if(m.campo==='tipoAcao' && Array.isArray(cli.tiposAcao) && cli.tiposAcao.length) v = cli.tiposAcao.join(', ');
if(m.campo==='dataContrato' && v) v = fmtD(v);
}
} else { /* manual */
v = (UI.docExtras && UI.docExtras[m.chave]) || '';
}
out[m.chave] = String(v==null?'':v);
});
return out;
}
var DOCS_MIGROU = false;
function docsMigrar(d){
DOCS_MIGROU = false;
if(!d || !Array.isArray(d.acoes)) return d;
d.acoes.forEach(a=>{ if(!a.area){ a.area = docsAreaPadrao(a.nome); DOCS_MIGROU = true; } });
return d;
}
/* ---------------------------------------------------------------
ONDE OS MODELOS FICAM GUARDADOS
O localStorage do navegador estoura em poucos megabytes, e um
escritório real tem dezenas de modelos, muitos deles longos. Por isso
os arquivos passaram a morar no IndexedDB, que aceita centenas de
megabytes: não há mais limite de quantidade de arquivos nem de páginas.
O localStorage guarda só a ficha — nome, ordem, área, tamanho —, para a
tela abrir na hora enquanto os arquivos são lidos em segundo plano.
--------------------------------------------------------------- */
const IDB_NOME='gescrit_docs', IDB_LOJA='modelos', IDB_CHAVE='docs';
let IDB_P = null;
function idbAbrir(){
if(IDB_P) return IDB_P;
IDB_P = new Promise((ok,falha)=>{
if(!window.indexedDB) return falha(new Error('sem IndexedDB'));
const r = indexedDB.open(IDB_NOME, 1);
r.onupgradeneeded = ()=>{ const db=r.result;
if(!db.objectStoreNames.contains(IDB_LOJA)) db.createObjectStore(IDB_LOJA); };
r.onsuccess = ()=>ok(r.result);
r.onerror = ()=>falha(r.error||new Error('IndexedDB indisponível'));
});
IDB_P.catch(()=>{ IDB_P=null; });
return IDB_P;
}
function idbGravar(valor){
return idbAbrir().then(db=>new Promise((ok,falha)=>{
const t=db.transaction(IDB_LOJA,'readwrite');
t.objectStore(IDB_LOJA).put(valor, IDB_CHAVE);
t.oncomplete = ()=>ok(true);
t.onerror = t.onabort = ()=>falha(t.error||new Error('não consegui gravar'));
}));
}
function idbLer(){
return idbAbrir().then(db=>new Promise((ok,falha)=>{
const t=db.transaction(IDB_LOJA,'readonly');
const r=t.objectStore(IDB_LOJA).get(IDB_CHAVE);
r.onsuccess = ()=>ok(r.result||null);
r.onerror = ()=>falha(r.error);
}));
}
function docsCarregar(){
try{ const raw=localStorage.getItem(KEY_MODELOS);
if(raw){ const d=JSON.parse(raw); if(d && Array.isArray(d.acoes)) return docsMigrar(d); } }catch(e){}
return {acoes: ACOES_DOC_PADRAO.map((x,i)=>({id:i+1, nome:x[0], area:x[1], modelos:[]})),
seq:ACOES_DOC_PADRAO.length+1};
}
let DOCS = docsCarregar();
/* a versão leve, sem os arquivos: é o que cabe no localStorage */
function docsFicha(){
return {acoes: (DOCS.acoes||[]).map(a=>Object.assign({}, a, {
modelos:(a.modelos||[]).map(m=>{ const o=Object.assign({}, m);
delete o.b64; o.semArquivo=true; return o; })
})),
seq:DOCS.seq, marcadores:DOCS.marcadores, em:DOCS.em, ficha:true};
}
/* Grava sem empurrar para o servidor — usado quando o próprio servidor
acabou de mandar a base para cá. */
function docsPersistir(){
DOCS.em = agoraISO();
return idbGravar(DOCS)
.then(()=>{ try{ localStorage.setItem(KEY_MODELOS, JSON.stringify(docsFicha())); }catch(e){} return true; })
.catch(()=>{
try{ localStorage.setItem(KEY_MODELOS, JSON.stringify(DOCS)); return true; }
catch(e){ toast('Este navegador não conseguiu guardar os modelos — o espaço acabou. '+
'No Chrome ou no Edge não há esse limite.'); return false; }
});
}
/* ---------------------------------------------------------------
ESTADO DA GRAVAÇÃO DOS MODELOS
Guardar um .docx é operação demorada e assíncrona: quem mexe na base
precisa ver com os próprios olhos que a alteração ficou guardada. Um
selo mostra a situação e o botão Salvar força a gravação e espera ela
terminar antes de confirmar.
--------------------------------------------------------------- */
const DOCS_EST = {sujo:false, gravando:false, em:'', erro:''};
function docsSelo(){
const el = $('doc-selo'); if(!el) return;
if(DOCS_EST.erro){ el.className='doc-selo erro'; el.textContent='⚠ '+DOCS_EST.erro; return; }
if(DOCS_EST.gravando){ el.className='doc-selo indo'; el.textContent='Gravando…'; return; }
if(DOCS_EST.sujo){ el.className='doc-selo pend'; el.textContent='● Alterações ainda não gravadas'; return; }
el.className='doc-selo ok';
el.textContent = DOCS_EST.em ? '✓ Tudo gravado às '+DOCS_EST.em : '✓ Tudo gravado';
}
function docsSalvar(){
DOCS_EST.sujo = true; DOCS_EST.erro = '';
docsSelo();
docsGravar();
return true;
}
function docsGravar(){
if(DOCS_EST.gravando) { DOCS_EST.refazer = true; return Promise.resolve(false); }
DOCS_EST.gravando = true; DOCS_EST.refazer = false;
docsSelo();
return docsPersistir().then(ok=>{
DOCS_EST.gravando = false;
if(ok){
DOCS_EST.sujo = false; DOCS_EST.erro = '';
const h = new Date();
DOCS_EST.em = String(h.getHours()).padStart(2,'0')+':'+String(h.getMinutes()).padStart(2,'0');
if(SRV.ativo && SRV.token) srvAgendar('modelos');
} else {
DOCS_EST.erro = 'Não consegui gravar os modelos neste navegador.';
}
docsSelo();
if(DOCS_EST.refazer) return docsGravar();
return ok;
}).catch(()=>{
DOCS_EST.gravando = false;
DOCS_EST.erro = 'Não consegui gravar os modelos. Tente o botão Salvar de novo.';
docsSelo();
return false;
});
}
/* O botão: grava, espera terminar e confirma. */
async function docsSalvarBotao(){
if(!ehAdmin()){ toast('Somente a Administração grava a base de documentos.'); return; }
const b = $('doc-salvar'); if(b){ b.disabled = true; b.textContent = 'Gravando…'; }
DOCS_EST.sujo = true; DOCS_EST.erro = '';
const ok = await docsGravar();
if(b){ b.disabled = false; b.textContent = '💾 Salvar alterações'; }
renderDocs();
toast(ok
? 'Modelos gravados. A base já está valendo para toda a equipe.'
: 'Não consegui gravar. Confira o espaço do navegador e tente de novo.');
}
/* sair da aba ou fechar a janela nunca deixa alteração pendente */
['pagehide','beforeunload'].forEach(ev=>window.addEventListener(ev, function(){
if(DOCS_EST.sujo && !DOCS_EST.gravando) try{ docsPersistir(); }catch(e){}
}));
/* Depois que a tela já abriu, os arquivos chegam do IndexedDB. */
async function docsCarregarArquivos(){
let d=null;
try{ d = await idbLer(); }catch(e){ d=null; }
if(!d || !Array.isArray(d.acoes)) return;
const mapa = {};
d.acoes.forEach(a=>(a.modelos||[]).forEach(m=>{ if(m && m.id!=null && m.b64) mapa[m.id]=m.b64; }));
if(!Object.keys(mapa).length) return;
const temAqui = (DOCS.acoes||[]).some(a=>(a.modelos||[]).some(m=>m.b64));
let mudou = false;
if(!temAqui && !(DOCS.acoes||[]).some(a=>(a.modelos||[]).length)){
DOCS = docsMigrar(d); mudou = true; /* localStorage limpo: o IndexedDB é a base */
} else {
(DOCS.acoes||[]).forEach(a=>(a.modelos||[]).forEach(m=>{
if(m && !m.b64 && mapa[m.id]){ m.b64 = mapa[m.id]; delete m.semArquivo; mudou = true; }
}));
}
if(mudou){ delete DOCS.ficha; if(UI.painel==='docs') renderDocs(); }
}
function docsTamanho(){
let n=0;
(DOCS.acoes||[]).forEach(a=>(a.modelos||[]).forEach(m=>{
n += m.tam!=null ? (+m.tam||0) : ((m.b64||'').length*0.75);
}));
return n;
}
function docsFaltaArquivo(){
return (DOCS.acoes||[]).some(a=>(a.modelos||[]).some(m=>!m.b64));
}
function acaoDoc(id){ return DOCS.acoes.find(a=>a.id===+id) || null; }
/* Um arquivo por vez pode ser grande; o teto existe só porque o envio ao
servidor tem limite de 64 MB por requisição. Quantidade de arquivos e de
páginas: sem limite. */
const DOC_MAX_ARQ = 40*1024*1024;
/* Quando cada modelo entra na geração: sempre, só quando houver
representante, ou só quando não houver. */
const DOC_QUANDO = [['sempre','Sempre'],['com','Só com representante'],['sem','Só sem representante']];
function docQuandoNome(k){ const x=DOC_QUANDO.find(y=>y[0]===(k||'sempre')); return x?x[1]:'Sempre'; }
/* Os modelos que valem para a geração atual. */
function docModelosDe(a, temRep){
if(!a) return [];
if(!a.rep) return (a.modelos||[]).slice();
return (a.modelos||[]).filter(m=>{
const q = m.quando||'sempre';
if(q==='com') return !!temRep;
if(q==='sem') return !temRep;
return true;
});
}
/* ---------- conversões ---------- */
function b64ParaBuffer(b64){
const bin=atob(b64), out=new Uint8Array(bin.length);
for(let i=0;i $(id) ? ($(id).value||'').trim()
: (chave!=null ? (ant[chave]||'') : '');
const numero = ($('doc-sn') && $('doc-sn').checked) ? 'S/N' : v('doc-numero','numero');
return {genero:g, nome:v('doc-nome','nome'), nacionalidade:v('doc-nac','nacionalidade'),
estadoCivil:v('doc-ec','estadoCivil'), profissao:v('doc-prof','profissao'),
doc:v('doc-doc','doc'), rg:v('doc-rg','rg'), orgao:v('doc-orgao','orgao'),
rua:v('doc-rua','rua'), numero:numero, bairro:v('doc-bairro','bairro'),
cidade:v('doc-cidade','cidade'), uf:v('doc-uf','uf'), cep:v('doc-cep','cep'),
valorTxt: v('doc-valor','valorTxt'),
valor: $('doc-valor') ? valorNumero($('doc-valor').value) : (ant.valor||0)};
}
/* ---------- representante ----------
Menor de idade, interdito, espólio: a peça muda de forma e ganha uma
segunda qualificação. Os dados vêm do cadastro do cliente quando ele já
está na carteira, ou são digitados aqui quando o cliente é avulso. */
function docRep(){
if(!UI.docRep) UI.docRep = {genero:'f'};
return UI.docRep;
}
function docRepDados(){
const r = docRep();
if(!$('rep-nome')) return r;
const v = (id, chave) => $(id) ? ($(id).value||'').trim() : (r[chave]||'');
UI.docRep = Object.assign({}, r, {
genero: r.genero==='f' ? 'f' : 'm',
nome:v('rep-nome','nome'), relacao:v('rep-rel','relacao'),
nacionalidade:v('rep-nac','nacionalidade'),
estadoCivil:v('rep-ec','estadoCivil'), profissao:v('rep-prof','profissao'),
doc:v('rep-doc','doc'), rg:v('rep-rg','rg'), orgao:v('rep-orgao','orgao')
});
return UI.docRep;
}
/* O endereço é informado uma vez só: o do representante é sempre o do
cliente, como pedem os modelos usados pelo escritório. */
function docRepResolvido(d){
const r = Object.assign({}, docRep());
r.rua=d.rua; r.numero=d.numero; r.bairro=d.bairro;
r.cidade=d.cidade; r.uf=d.uf; r.cep=d.cep;
return r;
}
function docTemRep(){
const a = acaoDoc(UI.docAcao);
return !!(a && a.rep && UI.docTemRep);
}
function frasesRepresentacao(d, r, cCli, cRep){
const c1 = cCli || docCamposAtivos(), c2 = cRep || docCamposAtivosRep();
if(!r || !r.nome) return {frase:'', completa:qualificacaoTexto(d, c1)};
const f = d.genero==='f';
const frase = 'neste ato '+(f?'representada':'representado')+' por '+
(r.relacao ? 'sua '+r.relacao+', ' : '')+qualificacaoTexto(r, c2);
return {frase:frase, completa:qualificacaoTexto(d, c1)+', '+frase};
}
function enderecoLinha(d){
const p=[];
if(d.rua) p.push(d.rua + (d.numero?', nº '+d.numero:''));
if(d.bairro) p.push('bairro '+d.bairro);
if(d.cidade) p.push(d.cidade + (d.uf?'/'+d.uf:''));
if(d.cep) p.push('CEP '+formatarCep(d.cep));
return p.join(', ');
}
/* ---------------------------------------------------------------
CAMPOS QUE PODEM FICAR DE FORA
Nem toda peça pede profissão, estado civil ou RG. Estes quatro campos
são ligáveis e desligáveis: desligado, o campo some do formulário, não
entra na qualificação e o marcador correspondente sai vazio — a linha
que existia só para ele desaparece, sem deixar espaço em branco.
--------------------------------------------------------------- */
const DOC_OPCIONAIS = [
['profissao', 'Profissão', ['PROFISSAO']],
['estadoCivil','Estado civil', ['ESTADO_CIVIL']],
['rg', 'RG', ['RG']],
['orgao', 'Órgão emissor', ['ORGAO_EMISSOR']]
];
/* o que vale agora: o que a pessoa marcou na tela, ou o padrão da ação */
function _camposDe(padrao, escolha){
const out = {};
padrao = padrao || {}; escolha = escolha || {};
DOC_OPCIONAIS.forEach(x=>{
const k = x[0];
out[k] = (escolha[k]!==undefined) ? !!escolha[k]
: (padrao[k]!==undefined ? !!padrao[k] : true);
});
return out;
}
function docCamposAtivos(){
const a = acaoDoc(UI.docAcao);
return _camposDe(a && a.campos, UI.docCampos);
}
/* O representante tem escolha própria: há peça que pede a profissão da mãe
e não a do menor, e vice-versa. Enquanto ninguém mexer, o representante
acompanha o que foi escolhido para o cliente. */
function docCamposAtivosRep(){
const a = acaoDoc(UI.docAcao);
const temPadraoRep = a && a.camposRep;
const temEscolhaRep = UI.docCamposRep;
if(!temPadraoRep && !temEscolhaRep) return docCamposAtivos();
return _camposDe(a && a.camposRep, UI.docCamposRep);
}
function docCampoLigado(k){ return docCamposAtivos()[k] !== false; }
function qualificacaoTexto(d, op){
const c = op || docCamposAtivos();
const f = d.genero==='f';
const p=[d.nome];
if(d.nacionalidade) p.push(d.nacionalidade);
if(c.estadoCivil && d.estadoCivil) p.push(d.estadoCivil);
if(c.profissao && d.profissao) p.push(d.profissao);
if(c.rg && d.rg) p.push((f?'portadora':'portador')+' do RG nº '+d.rg+
((c.orgao && d.orgao)?' — '+d.orgao:''));
if(d.doc) p.push((f?'inscrita':'inscrito')+' no '+(ehCNPJ(d.doc)?'CNPJ':'CPF')+' sob o nº '+formatarDoc(d.doc));
const end=enderecoLinha(d);
if(end) p.push((f?'residente e domiciliada':'residente e domiciliado')+' na '+end);
return p.join(', ');
}
function camposDoDocumento(d, nomeAcao, representante){
const r = representante || {};
const e = escritorio();
const hoje = hoje0();
const dataLonga = hoje.getDate()+' de '+MESES_NOME[hoje.getMonth()]+' de '+hoje.getFullYear();
const cidadeLocal = (e.endereco.match(/,\s*([^,]+?)\s*[\/-]\s*[A-Z]{2}/)||[])[1] || d.cidade || '';
const cnpj = ehCNPJ(d.doc);
const c = docCamposAtivos(); /* campos do cliente */
const cr = docCamposAtivosRep(); /* campos do representante */
const frases = frasesRepresentacao(d, r, c, cr);
/* campo desligado não vai ao documento, nem aqui nem na qualificação */
const so = (ligado, v) => ligado ? (v||'') : '';
return {
NOME:d.nome, QUALIFICACAO:qualificacaoTexto(d, c),
NACIONALIDADE:d.nacionalidade,
ESTADO_CIVIL: so(c.estadoCivil, d.estadoCivil),
PROFISSAO: so(c.profissao, d.profissao),
CPF_CNPJ:formatarDoc(d.doc), CPF:cnpj?'':formatarDoc(d.doc), CNPJ:cnpj?formatarDoc(d.doc):'',
RG: so(c.rg, d.rg),
ORGAO_EMISSOR: so(c.orgao, d.orgao),
RG_ORGAO: (c.rg && d.rg) ? (d.rg + ((c.orgao&&d.orgao)?' '+d.orgao:'')) : '',
ENDERECO:enderecoLinha(d), RUA:d.rua, NUMERO:d.numero, BAIRRO:d.bairro,
CIDADE:d.cidade, ESTADO:d.uf, CEP:formatarCep(d.cep),
ACAO:nomeAcao||'',
DATA:dataLonga, DATA_CURTA:fmtD(iso(hoje)),
LOCAL_DATA:(cidadeLocal? cidadeLocal+', ':'')+dataLonga,
ESCRITORIO:nomeEscritorio(), CNPJ_ESCRITORIO:e.cnpj, OAB_ESCRITORIO:e.oab, ENDERECO_ESCRITORIO:e.endereco,
VALOR: d.valor ? reaisTexto(d.valor) : '',
VALOR_EXTENSO: d.valor ? porExtenso(d.valor) : '',
REP_NOME:r.nome||'', REP_QUALIFICACAO: r.nome?qualificacaoTexto(r, cr):'',
REP_RELACAO:r.relacao||'', REP_NACIONALIDADE:r.nacionalidade||'',
REP_ESTADO_CIVIL: so(cr.estadoCivil, r.estadoCivil),
REP_PROFISSAO: so(cr.profissao, r.profissao),
REP_CPF_CNPJ: r.doc?formatarDoc(r.doc):'',
REP_RG: so(cr.rg, r.rg),
REP_ORGAO_EMISSOR: so(cr.orgao, r.orgao),
REP_RG_ORGAO: (cr.rg && r.rg) ? (r.rg + ((cr.orgao&&r.orgao)?' '+r.orgao:'')) : '',
REP_ENDERECO: r.nome?enderecoLinha(r):'', REP_RUA:r.rua||'', REP_NUMERO:r.numero||'',
REP_BAIRRO:r.bairro||'', REP_CIDADE:r.cidade||'', REP_ESTADO:r.uf||'',
REP_CEP: r.cep?formatarCep(r.cep):'',
REPRESENTACAO: frases.frase,
QUALIFICACAO_COMPLETA: frases.completa
};
}
/* O mapa completo: os de fábrica e os que a Administração criou. Os próprios
vêm depois, então um marcador criado com o mesmo nome de um de fábrica
prevalece — é o que a Administração quis dizer ao repetir a chave. */
function camposCompletos(d, nomeAcao, representante){
const base = camposDoDocumento(d, nomeAcao, representante);
const cli = UI.docCli ? cliente(UI.docCli) : null;
return Object.assign(base, marcValores(d, cli));
}
/* ---------- painel ---------- */
function renderDocs(){
const el=$('pane-docs'); if(!el) return;
const adm = ehAdmin();
if(UI.docAba==='modelos' && !adm) UI.docAba='gerar';
el.innerHTML =
'
'+
'
'+
''+
(adm?'':'')+
'
'+
''+
DOCS.acoes.filter(a=>a.modelos.length).length+' ação(ões) com modelo · '+
DOCS.acoes.reduce((s,a)=>s+a.modelos.length,0)+' documento(s) na base'+
'
' + (UI.docAba==='modelos' ? docsAbaModelos() : docsAbaGerar());
if(UI.docAba==='modelos') docsSelo();
}
function docAba(a){
/* sair da aba de modelos com alteração pendente grava antes */
if(UI.docAba==='modelos' && a!=='modelos' && DOCS_EST.sujo) docsGravar();
UI.docAba=a; renderDocs();
}
function docSemAcento(s){
return String(s||'').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
}
function docContarArea(k){ return DOCS.acoes.filter(a=>(a.area||'ger')===k).length; }
/* A grade responde a duas coisas: a área escolhida e o que está escrito na
busca. Com texto na busca, a procura passa por todas as áreas. */
function docGrade(){
const q = docSemAcento((UI.docBusca||'').trim());
const cartao = (a, mostrarArea) =>
'';
let lista = DOCS.acoes.slice().sort((a,b)=>a.nome.localeCompare(b.nome,'pt-BR'));
if(q){
lista = lista.filter(a=>docSemAcento(a.nome).indexOf(q)>=0);
if(!lista.length) return '
Nenhuma ação com esse nome. Confira a grafia ou limpe a busca.
Escolha primeiro a área e depois a ação para a qual os documentos serão gerados. '+
'Só aparecem prontas as ações que já têm modelos anexados pela Administração.
'+
'
'+docPasso1()+'
';
if(!sel) return h;
const clientes = S.clientes.slice().sort((a,b)=>a.nome.localeCompare(b.nome));
const d = UI.docTmp || {};
const campo = (id,rot,extra,largo)=>'
'+extra+'
';
const cAtivos = docCamposAtivos();
h += '
2Dados do cliente
'+
'
Puxe de um cliente já cadastrado ou preencha à mão. O que for digitado aqui entra em todos os documentos da ação escolhida.
'+
'
'+
'
'+
'Campos deste modelo'+
DOC_OPCIONAIS.map(x=>'').join('')+
'Desligue o que esta peça não pede: o campo some daqui, '+
'sai da qualificação e o marcador correspondente fica vazio — a linha que existia só para ele desaparece do '+
'documento, sem deixar espaço em branco.'+
'
O endereço é obrigatório: ele entra na qualificação de abertura das peças '+
'({{QUALIFICACAO}}) e é informado uma única vez — quando existe representante, '+
'a plataforma usa este mesmo endereço para os dois.
'+
marcCamposManuais()+
(ehRecibo(sel.nome)
? '
Valores do recibo
'+
'
O valor entra no documento em números, pelo marcador {{VALOR}}, e escrito por extenso, '+
'pelo {{VALOR_EXTENSO}}. O extenso é montado sozinho, conforme você digita.
Conferido automaticamente — é este texto que vai para o documento.
')+
'
'
: '')+
''+
'
'+
docBlocoRepresentante(sel)+
'
'+
'
'+
''+
''+
''+esc(sel.nome)+' · '+
docModelosDe(sel, docTemRep()).length+' de '+sel.modelos.length+
' documento(s) neste recorte · data de hoje aplicada automaticamente'+
(docsFaltaArquivo()?' · alguns arquivos ainda estão carregando':'')+''+
'
'+
'
';
return h;
}
/* ---------- bloco do representante ----------
Só aparece nas ações que a Administração marcou como "pode ter
representante". O botão liga e desliga o bloco inteiro e, junto com ele,
troca o conjunto de modelos que vai ser gerado. */
function docBlocoRepresentante(sel){
if(!sel || !sel.rep) return '';
const on = !!UI.docTemRep;
const r = docRep();
const campo = (rot,extra,largo)=>'
'+extra+'
';
const cOp = docCamposAtivosRep(); /* o representante tem escolha própria de campos */
const comRep = (sel.modelos||[]).filter(m=>(m.quando||'sempre')==='com').length;
const semRep = (sel.modelos||[]).filter(m=>(m.quando||'sempre')==='sem').length;
let h = '
3Representante
'+
'
Quando o cliente é menor de idade, interdito, ou o caso corre por espólio, a peça muda de forma e ganha '+
'a qualificação de quem representa. Ligue o botão abaixo para preencher esses dados — o conjunto de modelos '+
'gerado acompanha a escolha.
'+
'
'+
'';
if(!on) return h + '
';
h += '
'+
'Campos do representante'+
DOC_OPCIONAIS.map(x=>'').join('')+
'Escolha separada da do cliente: dá para levar a '+
'profissão da mãe sem levar a do menor, por exemplo. O que ficar de fora sai da qualificação do '+
'representante e deixa o marcador {{REP_…}} vazio, sem espaço em branco no documento.'+
'
O endereço é o mesmo informado no bloco 2 — é digitado uma única vez e serve '+
'ao cliente e ao representante, como pedem os modelos do escritório.
';
return h + '';
}
function docRepToggle(v){
UI.docTemRep = !!v;
if(v && !UI.docRep) UI.docRep = {genero:'f'};
renderDocs();
}
function docRepGenero(g){ docRepGuardar(); UI.docRep.genero = g; renderDocs(); }
function docRepGuardar(){ docRepDados(); }
/* Apaga o formulário depois de gerar, sem perder a ação escolhida. */
async function docLimpar(){
if(!await confirmar('Limpar os dados preenchidos?\nO tipo de ação escolhido continua selecionado, '+
'e nada é apagado do cadastro do cliente.')) return;
UI.docTmp = {}; UI.docExtras = {}; UI.docCli = null; UI.docGenero = 'm';
UI.docRep = null; UI.docTemRep = false; UI.docCampos = null; UI.docCamposRep = null;
renderDocs();
setTimeout(()=>{ const e=$('doc-nome'); if(e) e.focus(); }, 80);
toast('Formulário limpo — pronto para o próximo cliente.');
}
function docsAbaModelos(){
const mb = (docsTamanho()/1048576);
let h = '
'+
''+
''+
'Cada alteração já é gravada sozinha; '+
'o botão serve para gravar na hora e ter a confirmação na tela.'+
'
'+
'
Anexe aqui os modelos .docx de cada ação — quantos arquivos quiser, do tamanho que forem. '+
'No momento em que alguém escolher a ação, a plataforma preenche todos eles com os dados do cliente e devolve '+
'um Word único, cada documento começando em página nova, na ordem em que aparecem abaixo; páginas que ficarem '+
'inteiramente em branco são descartadas. Ligue Existe representante? na ação em que a peça muda quando '+
'há mãe, tutor, curador ou inventariante: aí cada modelo passa a dizer se entra sempre, só com ou '+
'só sem representante, e quem for gerar escolhe no momento.
'+
'
Marcadores disponíveis
'+
'
Escreva o marcador no lugar onde o dado deve entrar — inclusive dentro de tabelas, cabeçalhos e rodapés. O que ficar sem correspondência é apagado na geração.
'+
'
'+ MARCADORES.map(m=>
'').join('')+'
'+
marcPainel()+
'
Ações e modelos '+mb.toFixed(1)+' MB usados
'+
'';
const ordem = {}; AREAS_DOC.forEach((x,i)=>ordem[x.k]=i);
h += DOCS.acoes.slice().sort((a,b)=>{
const d=(ordem[a.area||'ger']||0)-(ordem[b.area||'ger']||0);
return d || a.nome.localeCompare(b.nome,'pt-BR');
}).map(a=>
'
'+
'Campos do cliente, por padrão'+
DOC_OPCIONAIS.map(x=>{
const lig = !(a.campos && a.campos[x[0]]===false);
return '';
}).join('')+
'Quem for gerar começa com esta escolha e pode '+
'mudá-la no momento. O que ficar de fora tem o marcador vazio, e a linha correspondente some do documento.'+
'
'+
(a.rep
? '
'+
'Campos do representante, por padrão'+
DOC_OPCIONAIS.map(x=>{
const lig = a.camposRep ? !(a.camposRep[x[0]]===false)
: !(a.campos && a.campos[x[0]]===false);
return '';
}).join('')+
''+
(a.camposRep ? 'Escolha própria do representante.'
: 'Enquanto ninguém mexer aqui, o representante acompanha os campos do cliente.')+
' Vale para os marcadores {{REP_…}} e para a frase de representação.'+
'
Nenhum modelo anexado — esta ação ainda não aparece pronta para gerar.
')+
'
').join('');
return h;
}
function docTamModelo(m){
const b = m.tam!=null ? +m.tam : (m.b64||'').length*0.75;
return b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.max(1,Math.round(b/1024))+' KB';
}
/* Liga o representante numa ação. É a mesma chave que faz aparecer o botão
no gerador e a coluna "quando entra" em cada modelo. */
function docRepAcao(id){
if(!ehAdmin()){ toast('Somente a Administração configura as ações da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
a.rep = !a.rep;
if(a.rep) (a.modelos||[]).forEach(m=>{ if(!m.quando) m.quando='sempre'; });
docsSalvar(); renderDocs();
toast(a.rep
? '"'+a.nome+'" agora pergunta se existe representante. Diga abaixo quais modelos entram em cada caso.'
: '"'+a.nome+'" volta a gerar sempre os mesmos modelos.');
}
function docQuando(id, i, v){
if(!ehAdmin()){ toast('Somente a Administração configura os modelos.'); return; }
const a=acaoDoc(id); if(!a || !a.modelos[i]) return;
a.modelos[i].quando = v||'sempre';
docsSalvar(); renderDocs();
}
/* padrão da ação para os campos opcionais */
function docCampoAcao(id, k){
if(!ehAdmin()){ toast('Somente a Administração configura as ações da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
/* o representante não muda de tabela junto: se ainda seguia o cliente,
congela-se aqui o que ele tinha */
if(a.rep && !a.camposRep) a.camposRep = Object.assign({}, _camposDe(a.campos, null));
a.campos = a.campos || {};
a.campos[k] = (a.campos[k]===false);
if(UI.docAcao===a.id){ UI.docCampos = null; UI.docCamposRep = null; }
docsSalvar(); renderDocs();
const nome = (DOC_OPCIONAIS.find(x=>x[0]===k)||['','esse campo'])[1];
toast(a.campos[k]===false
? nome+' do cliente sai dos documentos de "'+a.nome+'".'
: nome+' do cliente volta a entrar nos documentos de "'+a.nome+'".');
}
function docCampoAcaoRep(id, k){
if(!ehAdmin()){ toast('Somente a Administração configura as ações da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
if(!a.camposRep) a.camposRep = Object.assign({}, _camposDe(a.campos, null));
a.camposRep[k] = !a.camposRep[k];
if(UI.docAcao===a.id) UI.docCamposRep = null;
docsSalvar(); renderDocs();
const nome = (DOC_OPCIONAIS.find(x=>x[0]===k)||['','esse campo'])[1];
toast(a.camposRep[k]
? nome+' do representante volta a entrar nos documentos de "'+a.nome+'".'
: nome+' do representante sai dos documentos de "'+a.nome+'".');
}
/* ---------- interações ---------- */
function docEscolherAcao(id){
const trocou = UI.docAcao !== id;
UI.docAcao = (UI.docAcao===id ? null : id);
const a = acaoDoc(UI.docAcao);
if(a && !UI.docBusca) UI.docArea = a.area || 'ger'; /* a área da ação fica acesa */
if(trocou){ UI.docCampos = null; UI.docCamposRep = null; } /* a nova ação traz o próprio padrão */
renderDocs();
if(UI.docAcao) setTimeout(()=>{ const e=$('doc-nome'); if(e) e.focus(); }, 80);
}
function docGuardar(){
if(!$('doc-nome')) return;
UI.docTmp = docDados();
UI.docGenero = UI.docTmp.genero;
}
/* Liga ou desliga um dos campos opcionais. O que já estava digitado no
campo fica guardado — religar devolve tudo. */
function docCampoAlternar(k){
docGuardar(); docRepGuardar();
/* antes de mexer no cliente, o representante fica com a escolha que tinha —
senão ele acompanharia a mudança sem que ninguém tenha pedido */
if(!UI.docCamposRep) UI.docCamposRep = Object.assign({}, docCamposAtivosRep());
const atual = docCamposAtivos();
UI.docCampos = Object.assign({}, atual);
UI.docCampos[k] = !atual[k];
renderDocs();
const nome = (DOC_OPCIONAIS.find(x=>x[0]===k)||['','esse campo'])[1];
toast(UI.docCampos[k]
? nome+' do cliente volta a entrar nos documentos desta ação.'
: nome+' do cliente fica de fora: o marcador sai vazio e a linha dele some do documento.');
}
function docCampoRepAlternar(k){
docGuardar(); docRepGuardar();
const atual = docCamposAtivosRep();
UI.docCamposRep = Object.assign({}, atual);
UI.docCamposRep[k] = !atual[k];
renderDocs();
const nome = (DOC_OPCIONAIS.find(x=>x[0]===k)||['','esse campo'])[1];
toast(UI.docCamposRep[k]
? nome+' do representante volta a entrar nos documentos.'
: nome+' do representante fica de fora: o marcador {{REP_…}} sai vazio e a linha dele some.');
}
function docDica(){
const el=$('doc-doc-hint'); if(!el) return;
const n=soDigitos($('doc-doc').value).length;
el.textContent = n===11 ? 'CPF reconhecido — 11 dígitos.'
: n===14 ? 'CNPJ reconhecido — 14 dígitos.'
: 'O formato é reconhecido pela quantidade de dígitos.';
}
/* Os marcadores próprios marcados como "preencher na hora" viram campos aqui. */
function marcCamposManuais(){
const l = marcLista().filter(m=>m.origem==='manual');
if(!l.length) return '';
if(!UI.docExtras) UI.docExtras = {};
return '
Seus marcadores
'+
'
Campos criados por você na aba Modelos por ação. O que for digitado aqui entra nos '+
'documentos pelas chaves correspondentes.
'+
'
'+l.map(m=>
'
'+
''+
(m.descricao?'
'+esc(m.descricao)+'
':'')+'
').join('')+'
';
}
function marcExtraMudou(chave, v){
if(!UI.docExtras) UI.docExtras = {};
UI.docExtras[chave] = v;
docGuardar();
}
function docPuxarCliente(id){
UI.docCli = +id || null;
const c = UI.docCli ? cliente(UI.docCli) : null;
if(!c){
/* cadastro manual: o representante também passa a ser digitado à mão */
UI.docRep = null; UI.docTemRep = false;
renderDocs(); return;
}
const q = c.qualificacao || {};
UI.docGenero = q.genero || UI.docGenero || 'm';
UI.docTmp = Object.assign({}, docDados(), q, {
nome:c.nome||q.nome||'', doc:c.cpfCnpj||q.doc||'', uf:q.uf||c.estado||''
});
/* O cliente tem representante no cadastro? Então ele já vem preenchido. */
const rq = c.repQualificacao || {};
const temRep = !!(c.menorIdade && (c.repNome || rq.nome));
const a = acaoDoc(UI.docAcao);
if(temRep){
UI.docRep = Object.assign({genero:'f'}, rq, {
nome: c.repNome || rq.nome || '',
doc: c.repCpf || rq.doc || '',
relacao: c.repRelacao || rq.relacao || ''
});
if(a && a.rep) UI.docTemRep = true;
} else {
UI.docRep = null;
if(a && a.rep) UI.docTemRep = false;
}
renderDocs();
toast('Dados de '+c.nome.split(/\s+/)[0]+' carregados'+
(temRep ? ', com o representante do cadastro' : '')+'. Complete o que faltar.');
}
/* ---------- o painel dos marcadores próprios ---------- */
function marcPainel(){
const l = marcLista();
return '
Seus marcadores '+l.length+' criado(s)'+
'
'+
'
Os de cima vêm de fábrica. Aqui embaixo ficam os que você mesma cria: a plataforma monta a '+
'chave no padrão {{ASSIM}} a partir do nome e você diz ao que ele se refere e de onde o '+
'valor sai — do cadastro do cliente, dos dados do escritório, de um texto fixo ou de um campo que aparece '+
'na hora de gerar. Depois é só escrever a chave no .docx.
'+
(l.length
? '
'+l.map((m,i)=>{
const o = MARC_ORIGENS.find(x=>x.k===m.origem) || MARC_ORIGENS[0];
return '
'+
''+
''+
''+
''+
'
';
}).join('')+'
'
: '
Nenhum marcador próprio ainda. '+
'Crie o primeiro para levar ao documento algo que a plataforma ainda não conhece.
');
}
function marcAbrir(i){
if(!ehAdmin()){ toast('Somente a Administração cria marcadores.'); return; }
const m = (i!=null && i>=0) ? marcLista()[i] : null;
UI.marcEdit = (i!=null && i>=0) ? i : null;
$('mk-titulo').textContent = m ? 'Editar marcador' : 'Novo marcador';
const org = m ? m.origem : 'manual';
$('mk-corpo').innerHTML =
'
'+
campo('full','Como você chama este dado',true,
'',null,
'A chave do documento é montada a partir daqui, no padrão do Word.')+
campo('full','Chave no documento',true,
'
{{}}
',null,
'Preenchida sozinha, mas você pode ajustar. Só letras, números e sublinhado.')+
campo('full','Ao que se refere',false,
'')+
campo('full','De onde vem o valor',false,
'
Este marcador vira um campo a mais no passo 2 do '+
'Gerar documentos, logo abaixo dos dados do cliente. Fica em branco no documento se não for preenchido.
';
}
marcPrevia();
}
function marcNomeMudou(){
const n = $('mk-nome') ? $('mk-nome').value : '';
const ch = $('mk-chave');
/* enquanto a chave não for editada à mão, ela acompanha o nome */
if(ch && !UI.marcChaveManual) ch.value = marcChaveDe(n);
marcPrevia();
}
function marcChaveMudou(){
UI.marcChaveManual = true;
const ch = $('mk-chave');
if(ch) ch.value = marcChaveDe(ch.value);
marcPrevia();
}
function marcPrevia(){
const b = $('mk-prev'); if(!b) return;
const ch = $('mk-chave') ? $('mk-chave').value : '';
const k = UI.marcOrigem;
const de = k==='fixo' ? 'sempre o texto “'+String(UI.marcFixo||'').slice(0,40)+'”'
: k==='cliente' ? 'o campo “'+((MARC_CAMPOS_CLIENTE.find(c=>c[0]===UI.marcCampo)||['','—'])[1])+'” do cliente escolhido'
: k==='escritorio' ? 'o dado “'+((MARC_CAMPOS_ESCRITORIO.find(c=>c[0]===UI.marcCampo)||['','—'])[1])+'” do escritório'
: 'o que você digitar no formulário, na hora de gerar';
b.innerHTML = ch
? 'No documento você escreve {{'+esc(ch)+'}} e a plataforma troca por '+esc(de)+'.'
: 'Escreva o nome acima para a plataforma montar a chave.';
}
function marcSalvar(){
if(!ehAdmin()) return;
const erro = m => { const b=$('mk-err'); b.textContent=m; b.classList.add('on'); };
const nome = ($('mk-nome').value||'').trim();
const chave = marcChaveDe($('mk-chave').value||'');
if(!nome) return erro('Dê um nome ao marcador.');
if(!chave) return erro('A chave ficou vazia. Use letras e números no nome.');
const l = marcLista();
const chocou = l.some((x,i)=>x.chave===chave && i!==UI.marcEdit);
if(chocou) return erro('Já existe um marcador seu com a chave {{'+chave+'}}.');
const o = {nome:nome, chave:chave, descricao:($('mk-desc').value||'').trim(),
origem:UI.marcOrigem||'manual', campo:UI.marcCampo||'', fixo:UI.marcFixo||''};
if(o.origem==='fixo' && $('mk-fixo')) o.fixo = $('mk-fixo').value||'';
if(UI.marcEdit!=null && UI.marcEdit>=0) Object.assign(l[UI.marcEdit], o);
else l.push(o);
UI.marcChaveManual = false;
docsSalvar(); fechar('ov-marc'); renderDocs();
toast(marcPadraoTem(chave)
? 'Marcador salvo. Atenção: {{'+chave+'}} já existe de fábrica e o seu passa a prevalecer.'
: 'Marcador {{'+chave+'}} salvo. Escreva-o no .docx onde o dado deve entrar.');
}
async function marcApagar(i){
if(!ehAdmin()) return;
const m = marcLista()[i]; if(!m) return;
if(!await confirmar('Excluir o marcador {{'+m.chave+'}}?\n\n'+
'Os documentos que já usam essa chave passam a sair com o espaço em branco.')) return;
marcLista().splice(i,1);
docsSalvar(); renderDocs();
toast('Marcador excluído.');
}
function docNovaAcao(){
if(!ehAdmin()){ toast('Somente a Administração cria ações da base de documentos.'); return; }
const n = prompt('Nome da nova ação:');
if(!n || !n.trim()) return;
DOCS.seq = (DOCS.seq||DOCS.acoes.length)+1;
DOCS.acoes.push({id:DOCS.seq, nome:n.trim(), area: UI.docArea || docsAreaPadrao(n), modelos:[]});
docsSalvar(); renderDocs();
toast('Ação criada em '+nomeArea(UI.docArea||docsAreaPadrao(n))+'. Anexe os modelos e confira a área.');
}
function docMudarArea(id, k){
if(!ehAdmin()){ toast('Somente a Administração organiza a base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
a.area = k; docsSalvar(); renderDocs();
toast('"'+a.nome+'" agora está em '+nomeArea(k)+'.');
}
function docRenomear(id){
if(!ehAdmin()){ toast('Somente a Administração renomeia ações da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
const n=prompt('Novo nome da ação:', a.nome);
if(!n || !n.trim()) return;
a.nome=n.trim(); docsSalvar(); renderDocs();
}
async function docExcluirAcao(id){
if(!ehAdmin()){ toast('Somente a Administração exclui ações da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
if(!await confirmar('Excluir a ação "'+a.nome+'"?\n\nOs '+a.modelos.length+' modelo(s) anexados a ela são apagados junto.')) return;
DOCS.acoes = DOCS.acoes.filter(x=>x.id!==a.id);
docsSalvar(); renderDocs(); toast('Ação excluída.');
}
function docAnexar(id){
if(!ehAdmin()){ toast('Somente a Administração anexa modelos da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
let inp=$('doc-file');
if(!inp){
inp=document.createElement('input'); inp.type='file'; inp.id='doc-file';
inp.accept='.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
inp.multiple=true; inp.style.display='none'; document.body.appendChild(inp);
}
inp.onchange = async e=>{
const arqs=[...e.target.files]; e.target.value='';
if(!arqs.length) return;
let ok=0; const recusados=[];
for(let i=0;i1) toast('Lendo '+(i+1)+' de '+arqs.length+': '+f.name);
if(!/\.docx$/i.test(f.name)){ recusados.push(f.name+' (só aceito .docx)'); continue; }
/* sem teto de páginas: o limite existe só para não estourar o envio ao servidor */
if(f.size > DOC_MAX_ARQ){ recusados.push(f.name+' (acima de '+Math.round(DOC_MAX_ARQ/1048576)+' MB)'); continue; }
const buf = await f.arrayBuffer();
try{ await zipLer(buf); }catch(err){ recusados.push(f.name+' (não abriu — salve de novo como .docx)'); continue; }
a.modelos.push({id:agoraISO()+'-'+Math.random().toString(36).slice(2,8), nome:f.name,
b64:bufferParaB64(buf), tam:f.size, quando:'sempre', em:agoraISO(), por:eu().nome});
ok++;
}
if(ok) docsSalvar();
renderDocs();
if(ok) toast(ok+' modelo(s) anexado(s) a '+a.nome+'.'+(recusados.length?' '+recusados.length+' recusado(s).':''));
if(recusados.length && !ok) toast('Nenhum arquivo entrou: '+recusados[0]+'.');
};
inp.click();
}
async function docRemover(id, i){
if(!ehAdmin()){ toast('Somente a Administração remove modelos da base de documentos.'); return; }
const a=acaoDoc(id); if(!a || !a.modelos[i]) return;
if(!await confirmar('Remover o modelo "'+a.modelos[i].nome+'"?')) return;
a.modelos.splice(i,1); docsSalvar(); renderDocs();
}
function docMover(id, i, dir){
if(!ehAdmin()){ toast('Somente a Administração reordena modelos da base de documentos.'); return; }
const a=acaoDoc(id); if(!a) return;
const j=i+dir; if(j<0 || j>=a.modelos.length) return;
const x=a.modelos[i]; a.modelos[i]=a.modelos[j]; a.modelos[j]=x;
docsSalvar(); renderDocs();
}
/* ---------- geração ---------- */
/* O extenso acompanha o que está sendo digitado, sem esperar o documento. */
function docValorMudou(){
const ext = $('doc-valor-ext');
if(ext) ext.value = porExtenso(valorNumero($('doc-valor').value));
docGuardar();
}
async function gerarDocumentos(){
const a = acaoDoc(UI.docAcao);
if(!a){ toast('Escolha o tipo de ação.'); return; }
if(!a.modelos.length){ toast('Esta ação ainda não tem modelos anexados pela Administração.'); return; }
const d = docDados();
const temRep = docTemRep();
const r = temRep ? docRepResolvido(d) : {};
const usar = docModelosDe(a, temRep);
const err=$('doc-err');
const falta = [];
if(!d.nome) falta.push('Nome completo');
/* campo desligado não é cobrado */
if(docCampoLigado('profissao') && !d.profissao) falta.push('Profissão');
if(!d.doc) falta.push('CPF ou CNPJ');
/* o endereço abre a qualificação de toda peça: sem ele o documento sai capenga */
if(!d.rua) falta.push('Rua / logradouro');
if(!d.numero) falta.push('Número (ou marque “sem número”)');
if(!d.bairro) falta.push('Bairro');
if(!d.cidade) falta.push('Cidade');
if(!d.uf) falta.push('Estado');
if(!d.cep) falta.push('CEP');
if(ehRecibo(a.nome) && !(d.valor>0)) falta.push('Valor recebido');
if(temRep){
if(!r.nome) falta.push('Nome do representante');
if(!r.doc) falta.push('CPF ou CNPJ do representante');
}
if(falta.length){
err.textContent='Preencha: '+falta.join(', ')+'.'; err.classList.add('on');
return;
}
if(!usar.length){
err.textContent = temRep
? 'Nenhum modelo desta ação está marcado para entrar quando existe representante.'
: 'Nenhum modelo desta ação está marcado para entrar quando não existe representante.';
err.classList.add('on'); return;
}
const semArquivo = usar.filter(m=>!m.b64);
if(semArquivo.length){
err.textContent = 'Os arquivos ainda estão sendo lidos ('+semArquivo.length+
' pendente(s)). Aguarde um instante e gere de novo.';
err.classList.add('on');
try{ await docsCarregarArquivos(); }catch(e){}
return;
}
err.classList.remove('on');
const b=$('doc-gerar'); b.disabled=true; b.textContent='Montando o documento…';
try{
const arquivos = usar.map(m=>({nome:m.nome, dados:b64ParaBuffer(m.b64)}));
const saida = await docxUnir(arquivos, camposCompletos(d, a.nome, r));
/* confere o arquivo antes de entregar: o Word é rigoroso, e é melhor
avisar aqui do que a pessoa descobrir na hora de abrir */
await conferirDocx(saida);
const nome = 'documentos-'+slugArq(a.nome)+'-'+slugArq(d.nome)+'.docx';
const ok = await baixar(nome, new Blob([saida],
{type:'application/vnd.openxmlformats-officedocument.wordprocessingml.document'}),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', {semTxt:true});
if(ok){
toast(usar.length+' documento(s) gerados em um arquivo só'+
(temRep?', na versão com representante':'')+'. Use "Limpar dados" para começar o próximo.');
/* a qualificação fica guardada no cliente para a próxima vez */
if(UI.docCli){ const c=cliente(UI.docCli);
if(c){ c.qualificacao=d; if(temRep) c.repQualificacao=r; salvar(); } }
} else {
toast('Não consegui entregar o arquivo neste ambiente. Abra a plataforma pelo endereço publicado e gere de novo.');
}
}catch(e){
err.textContent='Não consegui montar o documento: '+(e && e.message ? e.message : 'erro ao ler um dos modelos')+'.';
err.classList.add('on');
}
b.disabled=false; b.textContent='⬇ Gerar documento único (.docx)';
}
/* =========================================================
ACESSO — login, senha e sessão
A plataforma roda inteiramente no navegador: este login é uma
tranca de uso, não um cofre. As senhas nunca são guardadas em
texto puro — só o resultado de um PBKDF2-HMAC-SHA256 com sal
próprio de cada pessoa.
========================================================= */
const KEY_SESSAO = 'gescrit_sessao_v1';
const ITER_SENHA = 60000;
const _K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
function _sha256(msg){
const len=msg.length, bitLen=len*8;
const withPad=new Uint8Array((((len+8)>>6)+1)<<6);
withPad.set(msg); withPad[len]=0x80;
const dv=new DataView(withPad.buffer);
dv.setUint32(withPad.length-4, bitLen>>>0, false);
dv.setUint32(withPad.length-8, Math.floor(bitLen/4294967296), false);
let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a,
h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
const w=new Uint32Array(64);
for(let i=0;i>>7)|(a<<25))^((a>>>18)|(a<<14))^(a>>>3);
const s1=((b>>>17)|(b<<15))^((b>>>19)|(b<<13))^(b>>>10);
w[t]=(w[t-16]+s0+w[t-7]+s1)>>>0;
}
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,h=h7;
for(let t=0;t<64;t++){
const S1=((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7));
const ch=(e&f)^(~e&g);
const t1=(h+S1+ch+_K[t]+w[t])>>>0;
const S0=((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10));
const maj=(a&b)^(a&c)^(b&c);
const t2=(S0+maj)>>>0;
h=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0;
}
h0=(h0+a)>>>0; h1=(h1+b)>>>0; h2=(h2+c)>>>0; h3=(h3+d)>>>0;
h4=(h4+e)>>>0; h5=(h5+f)>>>0; h6=(h6+g)>>>0; h7=(h7+h)>>>0;
}
const out=new Uint8Array(32), odv=new DataView(out.buffer);
[h0,h1,h2,h3,h4,h5,h6,h7].forEach((v,i)=>odv.setUint32(i*4,v,false));
return out;
}
function _hmac(key,msg){
if(key.length>64) key=_sha256(key);
const k=new Uint8Array(64); k.set(key);
const ip=new Uint8Array(64), op=new Uint8Array(64);
for(let i=0;i<64;i++){ ip[i]=k[i]^0x36; op[i]=k[i]^0x5c; }
const inner=new Uint8Array(64+msg.length); inner.set(ip); inner.set(msg,64);
const ih=_sha256(inner);
const outer=new Uint8Array(96); outer.set(op); outer.set(ih,64);
return _sha256(outer);
}
function _pbkdf2(senha,sal,iter,dkLen){
const blocos=Math.ceil(dkLen/32), saida=new Uint8Array(blocos*32);
for(let i=1;i<=blocos;i++){
const bloco=new Uint8Array(sal.length+4); bloco.set(sal);
new DataView(bloco.buffer).setUint32(sal.length,i,false);
let u=_hmac(senha,bloco), acc=u.slice();
for(let j=1;jnew TextEncoder().encode(s);
const _hex = b=>[...b].map(x=>x.toString(16).padStart(2,'0')).join('');
const _bin = h=>{ const o=new Uint8Array(h.length/2);
for(let i=0;iemailNorm(m.email)===e) || null;
}
function validarSenhaNova(a,b){
if(!a) return 'Informe a nova senha.';
if(a.length<8) return 'A senha precisa ter ao menos 8 caracteres.';
if(!/[A-Za-zÀ-ÿ]/.test(a) || !/\d/.test(a)) return 'Misture letras e números — assim a senha resiste a tentativa e erro.';
if(a!==b) return 'As duas senhas não coincidem.';
return '';
}
/* senha provisória legível: evita 0/O e 1/l, que confundem ao ditar */
function senhaProvisoria(){
const L='ABCDEFGHJKMNPQRSTUVWXYZ', m='abcdefghijkmnpqrstuvwxyz', n='23456789';
const r=_aleatorio(12); const p=i=>r[i];
let s=L[p(0)%L.length];
for(let i=1;i<4;i++) s+=m[p(i)%m.length];
s+='-';
for(let i=4;i<8;i++) s+=n[p(i)%n.length];
s+='-';
for(let i=8;i<11;i++) s+=m[p(i)%m.length];
return s;
}
/* =========================================================
ANIVERSÁRIO
No primeiro acesso do dia, quem faz aniversário é recebido com
confete e o próprio nome. Uma vez só: a marca do dia fica no
navegador, por pessoa, e some sozinha depois.
========================================================= */
const KEY_ANIV = 'gescrit_aniv_v1';
function primeiroNome(n){ return String(n||'').trim().split(/\s+/)[0] || ''; }
/* Aceita AAAA-MM-DD (campo de data) e DD/MM/AAAA (digitado à mão). */
function diaMesDe(v){
const t = String(v||'').trim(); if(!t) return null;
let m = t.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if(m) return {d:+m[3], m:+m[2], ano:+m[1]};
m = t.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if(m) return {d:+m[1], m:+m[2], ano:+m[3]};
return null;
}
function fazAniversarioHoje(m, quando){
const dm = diaMesDe(m && m.nasc); if(!dm) return false;
const h = quando || new Date();
if(dm.d===29 && dm.m===2){
/* nascido em 29/02: nos anos comuns, a festa cai em 28/02 */
const bissexto = new Date(h.getFullYear(),1,29).getMonth()===1;
if(!bissexto) return h.getMonth()===1 && h.getDate()===28;
}
return (h.getMonth()+1)===dm.m && h.getDate()===dm.d;
}
function idadeQueFaz(m, quando){
const dm = diaMesDe(m && m.nasc); if(!dm || !dm.ano) return 0;
return (quando||new Date()).getFullYear() - dm.ano;
}
function anivJaMostrado(id, chaveDia){
try{ return localStorage.getItem(KEY_ANIV+':'+id) === chaveDia; }catch(e){ return false; }
}
function anivMarcar(id, chaveDia){
try{ localStorage.setItem(KEY_ANIV+':'+id, chaveDia); }catch(e){}
}
function festaAniversario(forcar){
const m = eu(); if(!m) return;
const h = new Date();
const chaveDia = h.getFullYear()+'-'+String(h.getMonth()+1).padStart(2,'0')+'-'+String(h.getDate()).padStart(2,'0');
if(!forcar){
if(!fazAniversarioHoje(m, h)) return;
if(anivJaMostrado(m.id, chaveDia)) return; /* já foi cumprimentada hoje */
anivMarcar(m.id, chaveDia);
}
const nome = primeiroNome(m.nome) || 'você';
const anos = idadeQueFaz(m, h);
const cx = document.getElementById('ov-aniv');
if(!cx) return;
document.getElementById('aniv-nome').textContent = 'Feliz aniversário, '+nome+'!';
document.getElementById('aniv-sub').innerHTML = anos>0
? 'Hoje são '+anos+' anos — que o novo ciclo venha leve e cheio de boas causas.'
: 'Que o novo ciclo venha leve e cheio de boas causas.';
document.getElementById('aniv-de').textContent = 'Com carinho, '+nomeEscritorio();
cx.classList.add('on');
anivConfete(true);
setTimeout(()=>{ const b=document.getElementById('aniv-x'); if(b) b.focus(); }, 300);
}
function fecharAniversario(){
const cx = document.getElementById('ov-aniv'); if(!cx) return;
cx.classList.remove('on');
anivConfete(false);
}
document.addEventListener('keydown', function(e){
const cx = document.getElementById('ov-aniv');
if(e.key==='Escape' && cx && cx.classList.contains('on')){ e.stopPropagation(); fecharAniversario(); }
}, true);
/* ---------- os confetes ----------
Papel picado desenhado em canvas: leve, sem biblioteca e sem imagem. */
var ANIV_ANIM = null;
function anivConfete(ligar){
const cv = document.getElementById('aniv-cv'); if(!cv) return;
if(ANIV_ANIM){ cancelAnimationFrame(ANIV_ANIM); ANIV_ANIM=null; }
if(!ligar){ const c=cv.getContext('2d'); c.clearRect(0,0,cv.width,cv.height); return; }
const ctx = cv.getContext('2d');
const dpr = Math.min(window.devicePixelRatio||1, 2);
const dim = ()=>{ cv.width = Math.floor(cv.clientWidth*dpr); cv.height = Math.floor(cv.clientHeight*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0); };
dim();
const L = cv.clientWidth, A = cv.clientHeight;
const cores = ['#e2b33c','#1e3a5f','#127a52','#b3261e','#1D6FE0','#8a4fbf','#e07a3f','#39b7a0','#e35d8a','#f2d264'];
const papeis = [];
const nascer = (n, alto) => { for(let i=0;i{
ctx.clearRect(0,0,L,A);
quadro++;
/* uma chuva contínua nos primeiros segundos, depois só o que ainda cai */
if(quadro<300 && quadro%7===0) nascer(4, true);
for(let i=papeis.length-1;i>=0;i--){
const p = papeis[i];
p.y += p.vy; p.x += p.vx + Math.sin((p.y+p.giro*40)/38)*0.8; p.giro += p.dgiro;
if(p.y > A+30){ papeis.splice(i,1); continue; }
ctx.save();
ctx.translate(p.x, p.y); ctx.rotate(p.giro);
ctx.fillStyle = p.cor;
ctx.globalAlpha = 0.92;
if(p.fita){ ctx.fillRect(-p.l/2, -1.6, p.l, 3.2); }
else { ctx.fillRect(-p.l/2, -p.a/2, p.l, p.a); }
ctx.restore();
}
if(papeis.length) ANIV_ANIM = requestAnimationFrame(passo);
else ANIV_ANIM = null;
};
ANIV_ANIM = requestAnimationFrame(passo);
window.addEventListener('resize', dim, {once:true});
}
/* ---------- sessão ---------- */
/* A sessão vive em sessionStorage: dura enquanto a aba estiver aberta e some
quando a página é encerrada ou o navegador fechado — na volta, login de novo.
Sair também a apaga na hora.
Com "Mantenha-me conectado", a mesma sessão fica guardada neste aparelho e
sobrevive ao fechamento do navegador, com prazo de validade: cinco dias
úteis. Vencido o prazo, ela é apagada e a tela de login volta. */
const LEMBRAR_DIAS_UTEIS = 5;
function proxDiaUtil(base, n){
const d = new Date(base.getTime());
let faltam = n;
while(faltam > 0){
d.setDate(d.getDate()+1);
const s = d.getDay();
if(s!==0 && s!==6) faltam--; /* sábado e domingo não contam */
}
d.setHours(23,59,59,999);
return d;
}
function limiteLembranca(){ return proxDiaUtil(new Date(), LEMBRAR_DIAS_UTEIS).toISOString(); }
function querLembrar(){ const c=$('login-lembrar'); return !!(c && c.checked); }
function lembranca(){
try{
const g = JSON.parse(localStorage.getItem(KEY_SESSAO)||'null');
if(!g || !g.ate) return null;
if(new Date(g.ate) <= new Date()){ localStorage.removeItem(KEY_SESSAO); return null; }
return g;
}catch(e){ return null; }
}
function gravarLembranca(dados){
try{
if(!querLembrar()){ localStorage.removeItem(KEY_SESSAO); return; }
localStorage.setItem(KEY_SESSAO, JSON.stringify(
Object.assign({}, dados, {ate: limiteLembranca()})));
}catch(e){}
}
function sessao(){
try{ const s = JSON.parse(sessionStorage.getItem(KEY_SESSAO)||'null'); if(s) return s; }catch(e){}
return lembranca();
}
function gravarSessao(m){
const dados = {id:m.id, email:m.email, em:agoraISO()};
try{ sessionStorage.setItem(KEY_SESSAO, JSON.stringify(dados)); }catch(e){}
gravarLembranca(dados);
}
function limparSessao(){
try{ sessionStorage.removeItem(KEY_SESSAO); }catch(e){}
try{ localStorage.removeItem(KEY_SESSAO); }catch(e){}
}
/* ---------- olho de visualização ---------- */
const OLHO_ABRE='';
const OLHO_FECHA='';
function verSenha(id, btn){
const el=document.getElementById(id); if(!el) return;
const ver = el.type==='password';
el.type = ver?'text':'password';
btn.innerHTML = ver?OLHO_FECHA:OLHO_ABRE;
btn.title = ver?'Ocultar a senha':'Mostrar a senha';
btn.setAttribute('aria-label', btn.title);
el.focus();
}
function resetarOlhos(escopo){
(escopo||document).querySelectorAll('.olho').forEach(b=>{
const inp=b.parentNode.querySelector('input');
if(inp) inp.type='password';
b.innerHTML=OLHO_ABRE; b.title='Mostrar a senha';
});
}
/* ---------- tela de login ---------- */
let LOGIN={modo:'entrar', membro:null, ocupado:false};
function loginErro(txt, campo, tipo){
const el=$('login-erro');
el.textContent=txt||'';
el.classList.toggle('on', !!txt);
el.classList.toggle('aviso', tipo==='aviso');
['lfw-email','lfw-senha','lfw-senha2'].forEach(id=>$(id).classList.remove('err'));
if(campo && tipo!=='aviso') $(campo).classList.add('err');
}
function loginOcupado(v, rotulo){
LOGIN.ocupado=v;
const b=$('login-ok'); b.disabled=v;
b.textContent = v ? (rotulo||'Verificando…') : LOGIN.rotulo;
}
function loginModo(modo, m){
LOGIN.modo=modo; LOGIN.membro=m||LOGIN.membro;
const criando = modo==='criar', trocando = modo==='trocar';
$('login-tit').textContent = criando?'Primeiro acesso' : trocando?'Defina sua senha' : 'Acessar a plataforma';
$('login-desc').textContent = criando
? 'Este e-mail ainda não tem senha. Crie a sua agora — ela é pessoal e intransferível.'
: trocando
? 'Você entrou com uma senha provisória. Escolha uma senha só sua para continuar.'
: 'Use o e-mail cadastrado pela Administração do escritório.';
$('login-senha-lb').textContent = (criando||trocando) ? 'Nova senha' : 'Senha';
$('login-senha').setAttribute('autocomplete', (criando||trocando)?'new-password':'current-password');
$('login-senha').placeholder = (criando||trocando)?'Crie uma senha':'Sua senha';
$('lfw-senha2').style.display = (criando||trocando)?'':'none';
$('login-email').readOnly = trocando;
LOGIN.rotulo = criando?'Criar senha e entrar' : trocando?'Salvar e entrar' : 'Entrar';
$('login-ok').textContent = LOGIN.rotulo;
$('login-esqueci').style.display = (criando||trocando)?'none':'';
$('login-voltar').style.display = criando?'':'none';
$('login-senha').value=''; $('login-senha2').value='';
resetarOlhos($('ov-login'));
loginErro('');
setTimeout(()=>{ const alvo = (criando||trocando||$('login-email').value) ? $('login-senha') : $('login-email');
try{ alvo.focus(); }catch(e){} }, 60);
}
function renderMarcaLogin(){
const marca = marcaLogin();
if(!$('login-logo')) return;
$('login-logo').className = 'login-logo'+(marca.larga?' larga':'');
$('login-logo').innerHTML = marca.html;
$('login-nome').textContent = nomeEscritorio();
/* a logo em faixa já carrega o nome do escritório — repetir logo abaixo polui */
$('login-nome').style.display = marca.larga ? 'none' : '';
}
function abrirLogin(){
document.documentElement.classList.remove('pre','pre-carregando','pre-login');
renderMarcaLogin();
if(SRV.ativo) srvMarca().then(function(ok){ if(ok) renderMarcaLogin(); });
$('ov-login').classList.add('on');
document.body.classList.add('travado','login-aberto');
closeDD();
document.querySelectorAll('.ov.on').forEach(o=>o.classList.remove('on'));
loginModo('entrar');
restaurarLembranca();
}
/* Com a lembrança viva, a caixa volta marcada e o e-mail já preenchido. */
function restaurarLembranca(){
const g=lembranca(), c=$('login-lembrar');
if(c) c.checked = !!g;
if(g && g.email && $('login-email') && !$('login-email').value) $('login-email').value = g.email;
}
function fecharLogin(){
document.documentElement.classList.remove('pre','pre-carregando','pre-login');
$('ov-login').classList.remove('on');
document.body.classList.remove('travado','login-aberto');
$('login-senha').value=''; $('login-senha2').value='';
/* rede de segurança da recorrência: séries interrompidas voltam a andar */
setTimeout(()=>{ try{ if(recReparar()) redesenhar(); }catch(e){} }, 400);
/* a plataforma já está na tela: se for o dia, o confete começa */
setTimeout(()=>{ try{ festaAniversario(); }catch(e){} }, 900);
}
function loginEmailMudou(){
if(LOGIN.modo==='trocar' || LOGIN.ocupado) return;
if(SRV.ativo) return; /* com servidor, é ele quem sabe quem está cadastrado */
const m=membroPorEmail($('login-email').value);
if(m && !m.auth){ if(LOGIN.modo!=='criar') loginModo('criar', m); }
else if(LOGIN.modo==='criar') loginModo('entrar');
}
/* A senha é da pessoa; a entrada depende de a Administração manter o cadastro
ativo. Quem ainda não foi liberado cria a senha e espera. */
function avisoSituacao(m){
if(!m || m.status==='ativo') return '';
if(m.status==='inativo') return 'Este acesso está inativo. Fale com a Administração do escritório.';
return 'Aguarde a Administração liberar o seu acesso. Sua senha já está guardada — assim que o seu cadastro for marcado como ativo, é só entrar com ela.';
}
function loginEsqueci(){
if(SRV.ativo){
loginErro('Peça uma senha provisória à Administração do escritório. Com ela você entra e a plataforma pede que crie a sua senha definitiva.');
return;
}
const adms=(S.membros||[]).filter(m=>m.perms && m.perms.gerir_equipe && m.status!=='inativo');
const contato = adms.length ? adms.map(m=>m.nome+(m.email?' ('+m.email+')':'')).join(', ') : 'a Administração do escritório';
loginErro('Para redefinir, peça uma senha provisória a '+contato+'. Com ela você entra e a plataforma pede que crie a sua senha definitiva.');
}
async function loginSubmit(ev){
if(ev) ev.preventDefault();
if(LOGIN.ocupado) return false;
if(SRV.ativo) return loginServidor();
const email=$('login-email').value.trim();
const s1=$('login-senha').value, s2=$('login-senha2').value;
loginErro('');
const m = LOGIN.modo==='trocar' ? LOGIN.membro : membroPorEmail(email);
if(!m){ loginErro('E-mail não cadastrado na equipe. Fale com a Administração do escritório.','lfw-email'); return false; }
if(LOGIN.modo==='entrar'){
if(!m.auth){ loginModo('criar', m); return false; }
if(!s1){ loginErro('Informe sua senha.','lfw-senha'); return false; }
loginOcupado(true);
let ok=false;
try{ ok = await conferirSenha(m, s1); }catch(e){ ok=false; }
loginOcupado(false);
if(!ok){ loginErro('Senha incorreta. Tente de novo ou peça uma senha provisória à Administração.','lfw-senha'); return false; }
/* a senha provisória serve para entrar e criar a definitiva, mesmo que o
cadastro ainda esteja aguardando a liberação da Administração */
if(m.auth.trocar){ LOGIN.membro=m; loginModo('trocar', m); $('login-email').value=m.email||'';
loginErro('Você entrou com a senha provisória. Agora crie a sua senha definitiva.', null, 'aviso'); return false; }
const espera = avisoSituacao(m);
if(espera){ loginErro(espera, null, m.status==='inativo'?null:'aviso'); return false; }
concluirEntrada(m, 'Bem-vinda de volta, '+String(m.nome||'').split(/\s+/)[0]+'.');
return false;
}
const erro = validarSenhaNova(s1, s2);
if(erro){ loginErro(erro, s1!==s2 && s1.length>=8 ? 'lfw-senha2':'lfw-senha'); return false; }
loginOcupado(true, 'Guardando…');
try{ m.auth = await criarCredencial(s1, false); }
catch(e){ loginOcupado(false); loginErro('Não consegui guardar a senha neste navegador.'); return false; }
loginOcupado(false);
salvar();
const espera = avisoSituacao(m);
if(espera){
loginModo('entrar');
$('login-email').value = m.email||'';
loginErro('Senha criada. '+espera, null, m.status==='inativo'?null:'aviso');
return false;
}
concluirEntrada(m, LOGIN.modo==='criar' ? 'Senha criada. Bom trabalho!' : 'Senha alterada. Bom trabalho!');
return false;
}
function concluirEntrada(m, msg){
S.usuario = m.id;
m.ultimoAcesso = agoraISO();
salvar(); gravarSessao(m);
try{ gravarUsuarioFin(); }catch(e){}
fecharLogin();
redesenhar();
if(msg) toast(msg);
}
async function sair(){
closeDD();
if(!await confirmar(SRV.ativo
? 'Sair da plataforma?\n\nOs dados continuam no servidor — só será preciso entrar de novo.'
: 'Sair da plataforma?\n\nOs dados continuam salvos neste computador — só será preciso entrar de novo.')) return;
if(SRV.ativo && SRV.token){
const tk=SRV.token; SRV.token=''; clearInterval(SRV.poll);
try{ await srvChamar('sair', {token:tk}); }catch(e){}
}
limparSessao();
abrirLogin();
toast('Você saiu da plataforma.');
}
/* Chamado no fim do carregamento: com sessão válida entra direto. */
async function iniciarAcesso(){
/* os modelos do gerador de documentos chegam do IndexedDB em paralelo */
try{ docsCarregarArquivos(); }catch(e){}
/* se o servidor demorar demais a responder, não se fica no escuro */
const socorro = setTimeout(function(){
if(document.documentElement.classList.contains('pre')){
document.documentElement.classList.remove('pre-carregando');
document.documentElement.classList.add('pre-login');
}
}, 4000);
try{ return await iniciarAcessoInterno(); }
finally{ clearTimeout(socorro); }
}
async function iniciarAcessoInterno(){
const comServidor = await srvDetectar();
/* guardado antes de qualquer limpeza: se a sessão cair, a escolha de
continuar conectado e o e-mail voltam para a tela de login */
const lembrado = lembranca();
if(comServidor){
const s=sessao();
if(s && s.srv && s.token){
SRV.token=s.token; SRV.membroId=s.id|0; SRV.email=s.email||'';
try{
await srvPuxar();
fecharLogin(); aplicarTema(); aplicarZoom(); aplicarSide(); redesenhar(); srvPolling();
return;
}catch(e){ SRV.token=''; limparSessao(); }
}
limparSessao();
abrirLogin();
const emailVolta = (s && s.email) || (lembrado && lembrado.email) || '';
if(emailVolta) $('login-email').value = emailVolta;
/* a sessão caiu, mas a escolha de continuar conectado permanece */
if(lembrado && $('login-lembrar')) $('login-lembrar').checked = true;
/* a marca chega logo em seguida e a tela se atualiza sozinha */
srvMarca().then(function(ok){ if(ok){ renderMarcaLogin(); renderMarca(); } });
return;
}
const s=sessao();
const m=s ? membro(s.id) : null;
const confere = m && emailNorm(m.email)===emailNorm(s.email) && m.auth && m.status==='ativo' && !m.auth.trocar;
if(confere){ S.usuario=m.id; salvar(); try{ gravarUsuarioFin(); }catch(e){} fecharLogin(); redesenhar(); return; }
limparSessao();
abrirLogin();
const emailLocal = (m && m.email) || (lembrado && lembrado.email) || '';
if(emailLocal) $('login-email').value = emailLocal;
if(lembrado && $('login-lembrar')) $('login-lembrar').checked = true;
}
/* ---------- menu da conta, no cabeçalho ---------- */
function renderMenuEu(){
const u=eu(), el=$('dd-eu'); if(!u||!el) return;
el.innerHTML =
'
'+esc(u.nome)+'
'+
'
'+esc(u.email||'sem e-mail cadastrado')+'
'+
'
'+esc(u.funcao)+' · '+(u.escopo==='meu'?'vê o que é seu':'vê todos os registros')+'
Só quem está cadastrado aqui consegue entrar, e cada pessoa cria a própria senha no primeiro acesso. '+
'Quando alguém esquecer a senha, gere uma provisória, entregue a ela e a plataforma exigirá uma senha nova logo na entrada.
'+
(linhas||'
Nenhum integrante cadastrado.
')+
'';
}
h += '
Sobre a segurança
'+
'
Nenhuma senha é guardada em texto: fica só o resultado de um cálculo irreversível (PBKDF2-SHA256, '+ITER_SENHA.toLocaleString('pt-BR')+' repetições, com sal próprio de cada pessoa). '+
(SRV.ativo
? 'A conferência acontece no servidor, e não no navegador: por isso é possível entrar de um aparelho que nunca abriu a plataforma. Cada pessoa só recebe do servidor os dados que o seu perfil permite alterar, e a senha de um integrante nunca é gravada por outro — a redefinição passa pelo botão de senha provisória.'
: 'Como esta cópia está rodando fora do servidor, o login é apenas uma tranca de uso: quem tiver acesso ao computador e conhecimento técnico alcança os dados salvos aqui.')+'
';
return h;
}
async function alterarMinhaSenha(){
const u=eu(), err=$('sn-erro'), botao=$('sn-ok');
const mostrar=t=>{ err.textContent=t; err.classList.toggle('on', !!t); };
const atual=$('sn-atual').value, nova=$('sn-nova').value, conf=$('sn-conf').value;
mostrar('');
if(u.auth){
botao.disabled=true; botao.textContent='Verificando…';
const ok=await conferirSenha(u, atual);
botao.disabled=false; botao.textContent='Alterar senha';
if(!ok){ mostrar('A senha atual não confere.'); return; }
}
const e=validarSenhaNova(nova, conf);
if(e){ mostrar(e); return; }
if(atual && atual===nova){ mostrar('A nova senha precisa ser diferente da atual.'); return; }
botao.disabled=true; botao.textContent='Guardando…';
u.auth = await criarCredencial(nova, false);
botao.disabled=false;
salvar(); gravarSessao(u); renderCfg();
toast('Senha alterada. Ela vale a partir do próximo acesso.');
}
async function gerarProvisoria(id){
if(!pode('gerir_equipe')){ toast('Somente a Administração redefine senhas.'); return; }
const m=membro(+id); if(!m) return;
if(!await confirmar('Gerar uma senha provisória para '+m.nome+'?\n\nA senha atual deixa de valer na hora, e no próximo acesso a plataforma vai pedir que '+
(m.id===S.usuario?'você crie':'a pessoa crie')+' uma senha nova.')) return;
const nova=senhaProvisoria();
if(SRV.ativo && SRV.token){
/* no servidor, quem grava a senha de outra pessoa é o próprio servidor */
let j=null;
try{ j = await srvChamar('provisoria', {membroId:m.id, senha:nova}); }catch(e){}
if(!j || !j.ok){ toast((j&&j.msg)||'Não consegui falar com o servidor.'); return; }
try{ await srvPuxar(['estado']); }catch(e){}
redesenhar();
} else {
m.auth = await criarCredencial(nova, true);
salvar();
}
renderCfg();
const caixa=$('prov-caixa');
if(caixa) caixa.innerHTML='
Senha provisória de '+esc(m.nome)+'
'+
'
'+esc(nova)+'
'+
'
Entregue pessoalmente ou por um canal que só '+
esc(String(m.nome||'').split(/\s+/)[0])+' acesse. Ela aparece uma única vez — se sumir da tela, gere outra.
'+
'
';
if(m.id===S.usuario){ limparSessao(); }
}
/* A conferência das notas fiscais não depende de abrir o menu financeiro:
a base fica gravada aqui do lado e é lida direto. */
setTimeout(function(){ try{ verificarNotas(); }catch(e){} }, 1800);
setInterval(function(){ try{ verificarNotas(); }catch(e){} }, 600000);
/* no celular, escolher um painel fecha a gaveta */
document.addEventListener('click', function(e){
if(!document.body.classList.contains('mob-menu')) return;
const alvo = e.target && e.target.closest && e.target.closest('#side-nav .mi');
if(alvo) setTimeout(fecharSideMobile, 80);
});
/* início */
UI.ordem = S.cfg.ordem || 'data-asc';
aplicarListas(); /* as listas personalizadas pela Administração entram antes das telas */
limparDescartes();
montarPaineis();
montarVisao();
montarRapido();
aplicarTema(); aplicarZoom(); aplicarSide(); redesenhar();
iniciarAcesso();
try{ gravarUsuarioFin(); }catch(e){} /* assina as cobranças no módulo financeiro */
try{ aplicarPedidosFin(); }catch(e){} /* dispensas feitas com o módulo em aba separada */
if(!S.clientes.length && !S.tarefas.length && S.membros.length===1){
setTimeout(()=>toast('Comece por Configuração › Equipe e Clientes — depois use + Criar.'), 700);
}