PageSourceSearch

https://mais-saude-empresarial.pages.dev/js/views/config.js

js mais-saude-empresarial.pages.dev collected 2026-10-03 05:24:35 UTC 17,356 bytes, 311 lines download raw bytes

1import { api } from '../api.js';
2import { esc, BRL } from '../utils.js';
3import { toast } from '../ui/toast.js';
4import { modalShow, closeModal } from '../ui/modal.js';
5import { isMaster } from '../state.js';
6
7export async function viewConfig() {
8  const [cfg, planosRes] = await Promise.all([
9    api.get('/config'),
10    api.get('/planos'),
11  ]);
12  const planos = planosRes.items || planosRes;
13  setTimeout(() => bind(planos), 0);
14
15  return `
16    <div class="card"><div class="hd"><h3>Configurações gerais</h3></div>
17      <div class="bd" style="padding:18px">
18        <div class="field"><label>Dia de corte (inclusão/cancelamento)</label>
19          <input id="cf_dia_corte" type="number" min="1" max="28" value="${esc(cfg.dia_corte || '15')}"></div>
20        <div class="field"><label>WhatsApp Central (com DDI, só dígitos)</label>
21          <input id="cf_wa_central" value="${esc(cfg.whatsapp_central || '')}"></div>
22        <div class="field"><label>WhatsApp Financeiro</label>
23          <input id="cf_wa_fin" value="${esc(cfg.whatsapp_financeiro || '')}"></div>
24        <div class="field"><label>Nome da Central</label>
25          <input id="cf_nome" value="${esc(cfg.nome_central || 'Mais Saúde Card')}"></div>
26        <button class="btn pri" id="cf_save">Salvar</button>
27      </div>
28    </div>
29
30    <div class="card"><div class="hd"><h3>💬 Envios WhatsApp</h3></div>
31      <div class="bd" style="padding:18px">
32        <label style="display:flex;align-items:center;gap:10px;font-weight:normal;cursor:pointer">
33          <input type="checkbox" id="cf_wa_ativo" ${cfg.wa_envios_ativo === '1' ? 'checked' : ''}>
34          <span><b>Envios WhatsApp ativos</b> (kill switch global)</span>
35        </label>
36        <p class="mini" style="margin-top:8px;color:var(--mut)">
37          Quando desligado, nenhuma mensagem é enviada — nem automática de NF nem manual.
38          A empresa também precisa ter <b>wa_ativo=1</b> individualmente.
39        </p>
40      </div>
41    </div>
42
43    <div class="card"><div class="hd"><h3>✉️ Envios Email</h3></div>
44      <div class="bd" style="padding:18px">
45        <label style="display:flex;align-items:center;gap:10px;font-weight:normal;cursor:pointer">
46          <input type="checkbox" id="cf_email_ativo" ${cfg.email_envios_ativo === '1' ? 'checked' : ''}>
47          <span><b>Envios Email ativos</b> (kill switch global)</span>
48        </label>
49        <p class="mini" style="margin-top:8px;color:var(--mut)">
50          Quando desligado, nenhum email sai — nem automático nem manual.
51          A empresa também precisa ter <b>email_ativo=1</b> individualmente.
52        </p>
53        ${isMaster() ? '<button class="btn sm" id="cf_smtp_edit" style="margin-top:12px">⚙ Configurar servidor SMTP…</button>' : ''}
54        <div id="cf_smtp_status" class="mini" style="margin-top:8px;color:var(--mut)">Carregando status do SMTP…</div>
55      </div>
56    </div>
57
58    <div class="card"><div class="hd"><h3>Planos do catálogo</h3><div class="sp"></div>
59      <span class="mini">${planos.length} plano${planos.length !== 1 ? 's' : ''} ativo${planos.length !== 1 ? 's' : ''}</span>
60      ${isMaster() ? '<button class="btn pri sm" data-novo-plano>+ Novo plano</button>' : ''}
61    </div>
62      <div class="bd" style="overflow:auto"><table>
63        <thead><tr>
64          <th>Nome</th><th>Tipo</th><th>Titular</th><th>Dep</th><th>Dep extra</th><th>Max deps</th><th class="noprint">Ações</th>
65        </tr></thead>
66        <tbody>${planos.map(p => `
67          <tr${p.novo_bloqueado ? ' style="opacity:.65"' : ''}>
68            <td><b>${esc(p.nome)}</b>${p.novo_bloqueado ? ' <span class="bdg b-bad" style="font-size:10px;margin-left:6px">🚫 Bloqueado para novas adesões</span>' : ''}</td>
69            <td><span class="bdg b-info">${esc(p.tipo)}</span></td>
70            <td><b>${BRL(p.valor_titular_padrao / 100)}</b></td>
71            <td>${p.permite_dependentes
72              ? (p.cobra_dependente ? `${BRL((p.valor_dependente_padrao || 0) / 100)}/dep` : 'grátis')
73              : '—'}</td>
74            <td>${(p.valor_dep_extra || 0) > 0 ? `${BRL(p.valor_dep_extra / 100)} (acima do ${p.max_dependentes || 0}º)` : '—'}</td>
75            <td>${p.max_dependentes ?? '∞'}</td>
76            <td class="noprint">
77              ${isMaster() ? `<button class="btn sm" data-edit-plano="${p.id}">Editar</button>` : ''}
78            </td>
79          </tr>`).join('')}
80        </tbody>
81      </table></div>
82    </div>
83    <p class="mini">Alterações no plano afetam apenas <b>novas inscrições</b>. Pra recalcular inscrições existentes usa <b>Preços negociados</b> na ficha da empresa (aplica em todas as ATIVAS).</p>
84  `;
85}
86
87function bind(planos) {
88  document.getElementById('cf_save').onclick = async () => {
89    const updates = {
90      dia_corte: document.getElementById('cf_dia_corte').value,
91      whatsapp_central: document.getElementById('cf_wa_central').value,
92      whatsapp_financeiro: document.getElementById('cf_wa_fin').value,
93      nome_central: document.getElementById('cf_nome').value,
94    };
95    try {
96      for (const [k, v] of Object.entries(updates)) {
97        await api.put('/config/' + k, { valor: v });
98      }
99      toast('Configurações salvas');
100    } catch (e) { toast('Erro: ' + e.message); }
101  };
102  document.getElementById('cf_email_ativo')?.addEventListener('change', async (e) => {
103    const v = e.target.checked ? '1' : '0';
104    try {
105      await api.put('/config/email_envios_ativo', { valor: v });
106      toast(v === '1' ? '✔ Envios Email ATIVADOS' : '⏸ Envios Email desligados');
107    } catch (err) {
108      e.target.checked = !e.target.checked;
109      toast('Erro: ' + (err.body?.error || err.message));
110    }
111  });
112  document.getElementById('cf_smtp_edit')?.addEventListener('click', abrirModalSmtp);
113  atualizarStatusSmtp();
114  document.getElementById('cf_wa_ativo')?.addEventListener('change', async (e) => {
115    const v = e.target.checked ? '1' : '0';
116    try {
117      await api.put('/config/wa_envios_ativo', { valor: v });
118      toast(v === '1' ? '✔ Envios WhatsApp ATIVADOS' : '⏸ Envios WhatsApp desligados');
119    } catch (err) {
120      e.target.checked = !e.target.checked;
121      toast('Erro: ' + (err?.body?.error || err?.message || 'falha'));
122    }
123  });
124  document.querySelector('[data-novo-plano]')?.addEventListener('click', () => modalPlano(null));
125  document.querySelectorAll('[data-edit-plano]').forEach(el => {
126    el.addEventListener('click', () => {
127      const p = planos.find(x => x.id === +el.dataset.editPlano);
128      modalPlano(p);
129    });
130  });
131}
132
133function modalPlano(p) {
134  const novo = !p;
135  p = p || { nome: '', tipo: 'INDIVIDUAL', permite_dependentes: 0, max_dependentes: null,
136             cobra_dependente: 0, valor_titular_padrao: 0, valor_dependente_padrao: 0, valor_dep_extra: 0 };
137  const brl = (v) => ((v || 0) / 100).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
138
139  modalShow(novo ? 'Novo plano' : `Editar: ${p.nome}`, `
140    <div class="field"><label>Nome do plano *</label>
141      <input id="pl_nome" value="${esc(p.nome)}" placeholder="Ex: Familiar + Odonto"></div>
142    <div style="display:flex;gap:10px">
143      <div class="field" style="flex:1"><label>Tipo *</label>
144        <select id="pl_tipo">
145          <option value="INDIVIDUAL" ${p.tipo === 'INDIVIDUAL' ? 'selected' : ''}>Individual</option>
146          <option value="FAMILIAR" ${p.tipo === 'FAMILIAR' ? 'selected' : ''}>Familiar</option>
147          <option value="PREMIUM" ${p.tipo === 'PREMIUM' ? 'selected' : ''}>Premium</option>
148        </select>
149      </div>
150      <div class="field" style="flex:1"><label>Valor titular (R$) *</label>
151        <input id="pl_val_tit" type="text" inputmode="decimal" value="${brl(p.valor_titular_padrao)}"></div>
152    </div>
153    <hr style="margin:14px 0;border:0;border-top:1px solid var(--line)">
154    <h4 style="margin-bottom:8px;font-size:13px">Dependentes e adesões</h4>
155    <div class="chk-grupo">
156      <label class="chk-row"><input type="checkbox" id="pl_permite" ${p.permite_dependentes ? 'checked' : ''}>
157        <span><b>Permite dependentes</b><small>O titular pode incluir familiares neste plano.</small></span></label>
158      <label class="chk-row" id="pl_cobra_row" style="${p.permite_dependentes ? '' : 'display:none'}"><input type="checkbox" id="pl_cobra_dep" ${p.cobra_dependente ? 'checked' : ''}>
159        <span><b>Cobra por dependente (ilimitado)</b><small>Cada dependente paga o valor definido abaixo. Desmarcado: dependentes inclusos até o limite.</small></span></label>
160      <label class="chk-row"><input type="checkbox" id="pl_novo_bloq" ${p.novo_bloqueado ? 'checked' : ''}>
161        <span><b>🚫 Bloquear novas adesões</b><small>Inscrições atuais continuam valendo; só impede criar novas.</small></span></label>
162    </div>
163    <div id="pl_dep_box" style="${p.permite_dependentes ? '' : 'display:none'};padding:10px;background:var(--azul-cl2, #F6F9FE);border-radius:6px">
164      <div class="field"><label>Máx. deps inclusos <span class="mini" style="font-weight:400;text-transform:none">(deixe vazio = ∞)</span></label>
165        <input id="pl_max_deps" type="number" min="0" value="${p.max_dependentes ?? ''}"></div>
166      <div id="pl_val_dep_box" style="${p.cobra_dependente ? '' : 'display:none'}">
167        <div class="field"><label>Valor cobrado por dep (R$)</label>
168          <input id="pl_val_dep" type="text" inputmode="decimal" value="${brl(p.valor_dependente_padrao)}"></div>
169      </div>
170      <div id="pl_val_extra_box" style="${!p.cobra_dependente ? '' : 'display:none'}">
171        <div class="field"><label>Valor extra por dep acima do limite (R$) <span class="mini" style="font-weight:400;text-transform:none">(0 = não cobra)</span></label>
172          <input id="pl_val_extra" type="text" inputmode="decimal" value="${brl(p.valor_dep_extra)}"></div>
173      </div>
174    </div>
175  `, [
176    { t: 'Cancelar', fn: closeModal },
177    { t: novo ? 'Criar plano' : 'Salvar', cls: 'pri', fn: async () => {
178      const parseVal = (s) => Math.round(parseFloat((s || '').replace(/\./g, '').replace(',', '.') || '0') * 100);
179      const permite = document.getElementById('pl_permite').checked;
180      const cobra = permite && document.getElementById('pl_cobra_dep').checked;
181      const body = {
182        nome: document.getElementById('pl_nome').value.trim(),
183        tipo: document.getElementById('pl_tipo').value,
184        valor_titular_padrao: parseVal(document.getElementById('pl_val_tit').value),
185        permite_dependentes: permite ? 1 : 0,
186        max_dependentes: permite ? (parseInt(document.getElementById('pl_max_deps').value, 10) || null) : 0,
187        cobra_dependente: cobra ? 1 : 0,
188        valor_dependente_padrao: cobra ? parseVal(document.getElementById('pl_val_dep').value) : 0,
189        valor_dep_extra: (permite && !cobra) ? parseVal(document.getElementById('pl_val_extra').value) : 0,
190        novo_bloqueado: document.getElementById('pl_novo_bloq')?.checked ? 1 : 0,
191      };
192      if (!body.nome) return toast('Nome obrigatório');
193      if (body.valor_titular_padrao < 0) return toast('Valor titular inválido');
194      try {
195        if (novo) await api.post('/planos', body);
196        else await api.patch('/planos/' + p.id, body);
197        closeModal();
198        toast(novo ? 'Plano criado' : 'Plano atualizado');
199        import('../router.js').then(m => m.render());
200      } catch (e) {
201        toast('Erro: ' + (e.body?.error || e.message));
202      }
203    }},
204  ]);
205
206  // Toggles
207  document.getElementById('pl_permite').onchange = (e) => {
208    document.getElementById('pl_dep_box').style.display = e.target.checked ? '' : 'none';
209    document.getElementById('pl_cobra_row').style.display = e.target.checked ? '' : 'none';
210  };
211  document.getElementById('pl_cobra_dep').onchange = (e) => {
212    document.getElementById('pl_val_dep_box').style.display = e.target.checked ? '' : 'none';
213    document.getElementById('pl_val_extra_box').style.display = e.target.checked ? 'none' : '';
214  };
215}
216
217// -----------------------------------------------------------------------------
218// SMTP — master pode ver/editar servidor+usuário+senha+FROM sem que os valores
219// passem por logs ou repositório. Senha nunca sai do relay (endpoint só devolve
220// "smtp_pass_definida:true|false"). Deixar em branco no form = mantém a atual.
221// -----------------------------------------------------------------------------
222
223async function atualizarStatusSmtp() {
224  const el = document.getElementById('cf_smtp_status');
225  if (!el) return;
226  try {
227    const c = await api.get('/email/config');
228    const ok = c.configurado;
229    el.innerHTML = ok
230      ? `<span style="color:var(--verde,#0f9d6c)">● Configurado</span> · ${esc(c.smtp_user || '—')} via ${esc(c.smtp_host || '—')}:${esc(String(c.smtp_port || ''))}`
231      : `<span style="color:var(--verm,#b23a26)">● Não configurado</span> — clique em "Configurar servidor SMTP" pra completar`;
232  } catch (e) {
233    el.innerHTML = `<span style="color:var(--verm)">● Erro ao consultar relay</span>`;
234  }
235}
236
237async function abrirModalSmtp() {
238  let cfg;
239  try {
240    cfg = await api.get('/email/config');
241  } catch (e) {
242    toast('Não foi possível conectar ao relay: ' + (e.body?.detalhes || e.message));
243    return;
244  }
245  modalShow('Configuração do servidor SMTP', `
246    <p class="mini" style="margin-bottom:14px;color:var(--mut)">
247      Os valores são salvos no relay do VPS. A senha nunca é exibida —
248      <b>deixe em branco pra manter a atual</b>.
249    </p>
250    <div class="field"><label>Servidor SMTP</label>
251      <input id="sm_host" value="${esc(cfg.smtp_host || '')}" placeholder="mail.provedor.com.br"></div>
252    <div style="display:flex;gap:10px">
253      <div class="field" style="flex:0 0 120px"><label>Porta</label>
254        <input id="sm_port" type="number" value="${esc(String(cfg.smtp_port || 465))}"></div>
255      <div class="field" style="flex:1"><label>Conexão</label>
256        <input value="${(String(cfg.smtp_port) === '465' ? 'SSL/TLS (465)' : 'STARTTLS (587)')}" disabled></div>
257    </div>
258    <div class="field"><label>Usuário / e-mail remetente</label>
259      <input id="sm_user" value="${esc(cfg.smtp_user || '')}" placeholder="[email protected]"></div>
260    <div class="field"><label>Senha ${cfg.smtp_pass_definida ? '<span class="mini" style="color:var(--verde,#0f9d6c)">(definida — deixe em branco pra manter)</span>' : '<span class="mini" style="color:var(--verm)">(ainda não definida)</span>'}</label>
261      <input id="sm_pass" type="password" autocomplete="new-password" placeholder="${cfg.smtp_pass_definida ? '••••••••' : 'nova senha'}"></div>
262    <div class="field"><label>Nome do remetente (que aparece no email)</label>
263      <input id="sm_from" value="${esc(cfg.from_name || 'Mais Saúde Card')}" placeholder="Mais Saúde Card"></div>
264    <div id="sm_result" class="mini" style="margin-top:12px"></div>
265  `, [
266    { t: 'Salvar e testar', cls: 'pri', fn: async () => {
267      const btn = document.querySelector('.modal .btn.pri');
268      const res = document.getElementById('sm_result');
269      const body = {
270        smtp_host: document.getElementById('sm_host').value.trim(),
271        smtp_port: document.getElementById('sm_port').value.trim(),
272        smtp_user: document.getElementById('sm_user').value.trim(),
273        smtp_pass: document.getElementById('sm_pass').value,  // sem trim — senhas podem ter espaço
274        from_name: document.getElementById('sm_from').value.trim(),
275      };
276      if (!body.smtp_host || !body.smtp_user) {
277        toast('Servidor e usuário são obrigatórios'); return;
278      }
279      if (btn) { btn.disabled = true; btn.textContent = 'Salvando…'; }
280      res.innerHTML = '<span style="color:var(--mut)">Salvando…</span>';
281      try {
282        await api.put('/email/config', body);
283        res.innerHTML = '<span style="color:var(--mut)">Testando conexão SMTP…</span>';
284        const verify = await api.post('/email/verify', {});
285        if (verify.ok) {
286          res.innerHTML = '<span style="color:var(--verde,#0f9d6c)">✔ Conexão SMTP OK — servidor respondeu corretamente</span>';
287          toast('SMTP configurado e testado');
288          atualizarStatusSmtp();
289        } else {
290          res.innerHTML = `<span style="color:var(--verm)">✖ Servidor recusou: ${esc(verify.detalhes || 'erro')}</span><br><span class="mini">Config foi salva. Ajuste e clique de novo em "Salvar e testar".</span>`;
291        }
292      } catch (e) {
293        res.innerHTML = `<span style="color:var(--verm)">✖ ${esc(e.body?.detalhes || e.message)}</span>`;
294      } finally {
295        if (btn) { btn.disabled = false; btn.textContent = 'Salvar e testar'; }
296      }
297    }},
298    { t: 'Só testar', fn: async () => {
299      const res = document.getElementById('sm_result');
300      res.innerHTML = '<span style="color:var(--mut)">Testando conexão SMTP…</span>';
301      try {
302        const verify = await api.post('/email/verify', {});
303        if (verify.ok) res.innerHTML = '<span style="color:var(--verde,#0f9d6c)">✔ Conexão SMTP OK</span>';
304        else res.innerHTML = `<span style="color:var(--verm)">✖ Servidor recusou: ${esc(verify.detalhes || 'erro')}</span>`;
305      } catch (e) {
306        res.innerHTML = `<span style="color:var(--verm)">✖ ${esc(e.body?.detalhes || e.message)}</span>`;
307      }
308    }},
309    { t: 'Fechar', fn: closeModal },
310  ]);
311}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.