Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesConversasAutoreplyPage.vue
T
Leonardo 9966b5f175 Melissa: paginas nativas cfg-* + temas + textos com fundo + drawer WA
CHROME COMPARTILHADO + 18 PAGINAS NATIVAS
- MelissaConfigPage: chrome unico (header, drawer mobile, sidebar com Configuracoes
  + FAQ slot, main com Suspense). Replica fake-dialog right rule e fica flush
  com o config-aside global.
- 18 wrappers finos (~25 linhas cada): cfg-precificacao, cfg-descontos,
  cfg-excecoes, cfg-convenios, cfg-wa, cfg-wa-pessoal, cfg-wa-oficial,
  cfg-wa-templates, cfg-conversas-tags/autoreply/optouts/sla/bots,
  cfg-lembretes, cfg-creditos-wa, cfg-sms, cfg-email-templates,
  cfg-recursos-extras, cfg-recursos-extras-extrato, cfg-auditoria.
- Cada wrapper usa defineAsyncComponent + Suspense pra evitar race com
  tenantStore no boot (loading travado em alguns chooser-style pages).
- MelissaLayout: imports + SECOES + MELISSA_NON_CONFIG_SLUGS + render
  conditions atualizados pra cobrir os 18 slugs.

PAGINAS LEGADAS DETECTAM CONTEXTO MELISSA
- ConfiguracoesWhatsappChooserPage, WhatsappPage, TwilioWhatsappPage,
  SmsPage, RecursosExtrasPage, EmailTemplatesPage, AddonsExtratoPage,
  AgendadorPage, ConversasAutoreplyPage: route.startsWith('/melissa')
  decide se router.push vai pro slug Melissa ou /configuracoes legado.
- Anchors <a href="/configuracoes/..."> (que recarregavam pagina e
  vazavam o usuario do Melissa) trocados por RouterLink context-aware.
- MelissaAgenda.goSettings agora vai pra /melissa/agenda-config.

PERSONALIZAR > TEMAS
- melissaThemes.js: catalogo Freud/Klein/Jung (wallpaper + cor primaria
  + preset Lara/Nora + surface).
- Toggle de tema aplica tudo de uma vez; persistido em melissa_prefs.themeName.
- Boot resolve themeName -> imagem via fetch + data URL (sem guardar
  data URL gigante no DB).
- onCustomFileChange/onClearBg invalidam themeName quando user mexe no bg.

PERSONALIZAR > FUNDO NOS TEXTOS
- Pref textBgEnabled em melissa_prefs.
- MelissaHeroClock: prop textBg envolve relogio/data/saudacao/resumo
  em <span class="hero-text"> que ganha bg branco/preto 60% + borda
  + padding + radius quando o toggle esta on.
- Vars --m-hero-text-bg / --m-hero-text-border flipam com light/dark.

TOP + DOCK COM GRADIENT HORIZONTAL
- Var --m-band: preto 80% (dark) / branco 80% (light).
- .melissa-topbar-band: gradiente cor->transparente (right->left) atras
  dos botoes do topo.
- .melissa-dock: gradiente cor->transparente (left->right) atras dos pins.

MELISSANEGOCIO ABSORVE MINHA EMPRESA
- Adiciona logo upload + preview "cartao de visita" (computeds
  enderecoLinhas/redesValidas/temDados/logoDisplay + redeIcon helper).
- Normaliza dados legados do cfg-empresa: redes_sociais.{rede} virou {name}.
- Preview teleporta entre 3 destinos baseado no viewport:
  mobile -> drawer; mid-desktop -> sidebar; wide-desktop (>=1340px) ->
  painel flutuante FORA do fake dialog (ancora no right edge + 14px gap,
  altura segue conteudo, header alinhado com header do dialog).
- Remove cfg-empresa de melissaConfigGrupos.js + COMPONENT_MAP do
  MelissaConfiguracoes; grupo "Empresa & Plataforma" -> "Plataforma".

CRONOMETRO -> SESSAO AGENDADA
- MelissaCronometro emite session-end ao parar com paciente selecionado
  (threshold 5s pra ignorar start/stop acidental).
- MelissaLayout.onCronometroSessionEnd busca agenda_eventos do paciente
  no dia (tipo='sessao'), pega o mais recente e grava em
  extra_fields.cronometro_duracao_seg + cronometro_parado_em.
- Toast: sucesso ("X min salvos") ou warn ("sessao nao encontrada").

CONVERSATIONDRAWER WHATSAPP-LIKE
- Nova imagem whatsapp-bg.jpg (renomeada de hash random) usada como
  tile (380px) no .cd-msgs.
- Light: bege #efeae2 + multiply blend.
- Dark: #0b141a + camada 78% sobre o doodle.
- Bubbles WA-style (verde out / branco-dark in com tails) ja existiam.

EXTRATO RECURSOS EXTRAS
- Filtros 2-por-linha em Melissa (vs 1/4 no /configuracoes).
- Cards de Resumo teleportam pro #cfg-page-side em Melissa
  (1-col empilhado no drawer; 4-col inline no /configuracoes).
- Botoes de exportar com flex-1 distribuidos em uma unica linha em
  desktop, wrap no mobile.
- DataTable scrollable em ambos os layouts.

OUTROS AJUSTES MENORES
- Cfg-conversas-autoreply: dias semana 4-cols em Melissa (vs 7-cols
  no /configuracoes).
- Cfg-creditos-wa: 1/2 por linha (vs 1/2/4) em Melissa.
- Cfg-recursos-extras: pacotes 1/2 (vs 1/2/4); "Em breve" 1-col.
- WhatsAppPage aba Templates: guia de formatacao teleporta pro side
  drawer em Melissa, deixando textareas full-width.
- ConfigPage chrome agora tem #cfg-page-actions target pros Teleport
  de acoes (refresh button etc).
- Imagens renomeadas em src/assets/themes/ (freudwebp/melainewebp/
  jungwebp.webp) e src/assets/whatsapp-bg.jpg.
- JoditTextEditor.vue novo (wrapper Jodit generico, sem features de email).
- MelissaConfigList.vue novo (lista compartilhada de configs pro drawer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:48:18 -03:00

415 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| Agência PSI Auto-reply fora do horário (CRM Grupo 2.3)
|--------------------------------------------------------------------------
| Configurações de resposta automática quando paciente manda mensagem fora
| do horário de atendimento.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useTenantStore } from '@/stores/tenantStore';
import { useAutoReplySettings } from '@/composables/useAutoReplySettings';
const route = useRoute();
const toast = useToast();
const tenantStore = useTenantStore();
const api = useAutoReplySettings();
// Em /melissa o card vive numa coluna mais estreita — quebra os 7 dias
// em 4+3 em vez de espremer 7 colunas. /configuracoes mantem 7 colunas.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
// Links pra outras paginas de config preservam o layout em que estao
// (Melissa vs /configuracoes), evitando "vazar" o usuario do Melissa.
const agendaPath = computed(() => (inMelissa.value ? '/melissa/agenda-config' : '/configuracoes/agenda'));
const whatsappPath = computed(() => (inMelissa.value ? '/melissa/cfg-wa' : '/configuracoes/whatsapp'));
const DIAS = [
{ dow: 0, label: 'Dom' },
{ dow: 1, label: 'Seg' },
{ dow: 2, label: 'Ter' },
{ dow: 3, label: 'Qua' },
{ dow: 4, label: 'Qui' },
{ dow: 5, label: 'Sex' },
{ dow: 6, label: 'Sáb' }
];
const agendaWindows = ref([]);
// ── Preview do status atual ─────────────────────────────────
const now = ref(new Date());
let _tick = null;
const currentWindows = computed(() => {
if (api.settings.value.schedule_mode === 'agenda') return agendaWindows.value;
if (api.settings.value.schedule_mode === 'business_hours') return api.settings.value.business_hours || [];
if (api.settings.value.schedule_mode === 'custom') return api.settings.value.custom_window || [];
return [];
});
function hhmmToMin(s) {
const m = String(s || '').match(/^(\d{1,2}):(\d{2})/);
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : -1;
}
const isWithinHoursNow = computed(() => {
const d = now.value;
// Hora de São Paulo
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Sao_Paulo',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const parts = fmt.formatToParts(d);
const weekday = parts.find((p) => p.type === 'weekday')?.value || 'Sun';
const h = parseInt(parts.find((p) => p.type === 'hour')?.value || '0', 10);
const mm = parseInt(parts.find((p) => p.type === 'minute')?.value || '0', 10);
const dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const dow = dowMap[weekday] ?? 0;
const mins = h * 60 + mm;
for (const w of currentWindows.value) {
if (w.dow !== dow) continue;
const s = hhmmToMin(w.start);
const e = hhmmToMin(w.end);
if (s < 0 || e < 0) continue;
if (mins >= s && mins < e) return true;
}
return false;
});
const nowLabel = computed(() => {
return new Intl.DateTimeFormat('pt-BR', {
timeZone: 'America/Sao_Paulo',
weekday: 'long',
hour: '2-digit',
minute: '2-digit'
}).format(now.value);
});
// ── Editor de janelas (business_hours / custom_window) ──────
function targetArrayKey() {
if (api.settings.value.schedule_mode === 'business_hours') return 'business_hours';
if (api.settings.value.schedule_mode === 'custom') return 'custom_window';
return null;
}
function addWindow(dow) {
const key = targetArrayKey();
if (!key) return;
const arr = [...(api.settings.value[key] || [])];
arr.push({ dow, start: '08:00', end: '18:00' });
arr.sort((a, b) => a.dow - b.dow || hhmmToMin(a.start) - hhmmToMin(b.start));
api.settings.value = { ...api.settings.value, [key]: arr };
}
function updateWindow(idx, patch) {
const key = targetArrayKey();
if (!key) return;
const arr = [...(api.settings.value[key] || [])];
arr[idx] = { ...arr[idx], ...patch };
api.settings.value = { ...api.settings.value, [key]: arr };
}
function removeWindow(idx) {
const key = targetArrayKey();
if (!key) return;
const arr = [...(api.settings.value[key] || [])];
arr.splice(idx, 1);
api.settings.value = { ...api.settings.value, [key]: arr };
}
function applyDefaultWindow() {
const key = targetArrayKey();
if (!key) return;
// Seg-Sex 09h-18h
const arr = [1, 2, 3, 4, 5].map((dow) => ({ dow, start: '09:00', end: '18:00' }));
api.settings.value = { ...api.settings.value, [key]: arr };
}
function clearWindows() {
const key = targetArrayKey();
if (!key) return;
api.settings.value = { ...api.settings.value, [key]: [] };
}
const editableWindows = computed(() => {
const key = targetArrayKey();
if (!key) return [];
return api.settings.value[key] || [];
});
// ── Ações ───────────────────────────────────────────────────
async function onSave() {
const res = await api.save();
if (res.ok) {
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
async function refreshAgenda() {
agendaWindows.value = await api.loadAgendaWindows();
}
// ── Lifecycle ───────────────────────────────────────────────
onMounted(async () => {
await Promise.all([api.load(), refreshAgenda()]);
_tick = setInterval(() => { now.value = new Date(); }, 30000);
});
watch(() => tenantStore.activeTenantId, async () => {
await Promise.all([api.load(), refreshAgenda()]);
});
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => { if (_tick) clearInterval(_tick); });
</script>
<template>
<!-- Ações no header do parent -->
<Teleport to="#cfg-page-actions" defer>
<Button
label="Salvar"
icon="pi pi-check"
size="small"
class="rounded-full"
:loading="api.saving.value"
:disabled="api.loading.value"
@click="onSave"
/>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="api.loading.value"
v-tooltip.bottom="'Recarregar'"
@click="api.load()"
/>
</Teleport>
<div class="flex flex-col gap-4">
<!-- Preview de status atual -->
<div
class="rounded-md border p-3 flex items-center gap-3"
:class="api.settings.value.enabled
? (isWithinHoursNow
? 'border-green-500/30 bg-green-500/5'
: 'border-amber-500/30 bg-amber-500/5')
: 'border-[var(--surface-border)] bg-[var(--surface-card)] opacity-80'"
>
<i
class="pi text-lg shrink-0"
:class="!api.settings.value.enabled
? 'pi-pause-circle text-[var(--text-color-secondary)]'
: isWithinHoursNow
? 'pi-check-circle text-green-500'
: 'pi-bolt text-amber-500'"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-[var(--text-color)]">
<template v-if="!api.settings.value.enabled">Auto-reply desabilitado</template>
<template v-else-if="isWithinHoursNow">Dentro do horário auto-reply NÃO disparará</template>
<template v-else>Fora do horário auto-reply disparará para novas mensagens</template>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5 capitalize">
{{ nowLabel }} · fuso São Paulo
</div>
</div>
</div>
<!-- Toggle + mensagem -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<div class="flex items-center gap-3 flex-wrap">
<label class="flex items-center gap-2 cursor-pointer">
<ToggleSwitch v-model="api.settings.value.enabled" />
<span class="text-sm font-semibold">
{{ api.settings.value.enabled ? 'Auto-reply ativado' : 'Auto-reply desativado' }}
</span>
</label>
<span class="text-xs text-[var(--text-color-secondary)]">
Envia resposta automática quando mensagem chega fora do horário configurado.
</span>
</div>
<div>
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Mensagem automática</label>
<Textarea
v-model="api.settings.value.message"
rows="3"
autoResize
:maxlength="2000"
class="w-full"
:disabled="!api.settings.value.enabled"
/>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1">
{{ (api.settings.value.message || '').length }} / 2000
</div>
</div>
<div>
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">
Cooldown por paciente: <span class="text-[var(--text-color)]">{{ api.settings.value.cooldown_minutes }} min</span>
</label>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<InputNumber
v-model="api.settings.value.cooldown_minutes"
:min="0"
:max="43200"
suffix=" min"
inputClass="!w-28"
class="shrink-0"
:disabled="!api.settings.value.enabled"
/>
<span class="text-xs text-[var(--text-color-secondary)] flex-1 min-w-0 leading-snug">
Tempo mínimo entre auto-replies para a mesma conversa (evita spam). Sugestão: 180 min (3h).
</span>
</div>
</div>
</div>
<!-- Modo de horário -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<div class="flex items-center gap-2">
<i class="pi pi-clock text-[var(--primary-color)]" />
<span class="text-sm font-bold uppercase tracking-wide">Quando disparar</span>
</div>
<SelectButton
v-model="api.settings.value.schedule_mode"
:options="[
{ label: 'Seguir minha agenda', value: 'agenda' },
{ label: 'Horário de funcionamento', value: 'business_hours' },
{ label: 'Personalizado', value: 'custom' }
]"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
:disabled="!api.settings.value.enabled"
/>
<div class="text-xs text-[var(--text-color-secondary)]">
<template v-if="api.settings.value.schedule_mode === 'agenda'">
Usa automaticamente os horários da <strong>Agenda</strong> (agenda_regras_semanais) de todos os profissionais ativos do tenant. Se alguém está trabalhando agora, é considerado "dentro do horário".
</template>
<template v-else-if="api.settings.value.schedule_mode === 'business_hours'">
Define um horário geral da clínica, reutilizável por outras automações. Independe das agendas individuais.
</template>
<template v-else>
Define um horário exclusivo para este auto-reply. Útil se você quer que responda antes/depois do expediente normal.
</template>
</div>
<!-- Preview das janelas do modo 'agenda' (read-only) -->
<div v-if="api.settings.value.schedule_mode === 'agenda'" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<div v-if="!agendaWindows.length" class="text-xs text-[var(--text-color-secondary)] italic">
Nenhuma regra semanal ativa encontrada na agenda. Configure em <RouterLink :to="agendaPath" class="text-[var(--primary-color)] underline">{{ inMelissa ? 'Agenda' : 'Configurações → Agenda' }}</RouterLink>.
</div>
<div v-else class="grid gap-1" :class="inMelissa ? 'grid-cols-4' : 'grid-cols-7'">
<div v-for="d in DIAS" :key="d.dow" class="flex flex-col items-center gap-1">
<div class="text-[0.7rem] font-semibold text-[var(--text-color-secondary)] uppercase">{{ d.label }}</div>
<div class="flex flex-col gap-0.5 w-full">
<div
v-for="(w, i) in agendaWindows.filter(x => x.dow === d.dow)"
:key="i"
class="text-[0.65rem] text-center font-mono rounded bg-[var(--primary-color)]/10 text-[var(--primary-color)] px-1 py-0.5"
>
{{ w.start }}{{ w.end }}
</div>
<div v-if="!agendaWindows.some(x => x.dow === d.dow)" class="text-[0.65rem] text-center text-[var(--text-color-secondary)] opacity-40"></div>
</div>
</div>
</div>
</div>
<!-- Editor de janelas (business_hours / custom) -->
<div v-else class="flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<Button
label="Aplicar padrão Seg-Sex 09h-18h"
icon="pi pi-calendar"
size="small"
severity="secondary"
outlined
class="rounded-full"
:disabled="!api.settings.value.enabled"
@click="applyDefaultWindow"
/>
<Button
label="Limpar"
icon="pi pi-times"
size="small"
severity="danger"
text
:disabled="!api.settings.value.enabled || !editableWindows.length"
@click="clearWindows"
/>
</div>
<div
class="grid gap-1.5 border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)] p-2"
:class="inMelissa ? 'grid-cols-4' : 'grid-cols-7'"
>
<div v-for="d in DIAS" :key="d.dow" class="flex flex-col gap-1 min-h-[80px]">
<div class="text-[0.7rem] font-bold text-[var(--text-color-secondary)] uppercase text-center">{{ d.label }}</div>
<div class="flex flex-col gap-1">
<div
v-for="(w, idx) in editableWindows.map((x, i) => ({ ...x, _idx: i })).filter(x => x.dow === d.dow)"
:key="w._idx"
class="flex flex-col gap-0.5 bg-[var(--surface-card)] rounded p-1"
>
<input
type="time"
:value="w.start"
class="text-[0.68rem] px-1 py-0.5 rounded border border-[var(--surface-border)] bg-[var(--surface-card)] w-full"
:disabled="!api.settings.value.enabled"
@change="(e) => updateWindow(w._idx, { start: e.target.value })"
/>
<input
type="time"
:value="w.end"
class="text-[0.68rem] px-1 py-0.5 rounded border border-[var(--surface-border)] bg-[var(--surface-card)] w-full"
:disabled="!api.settings.value.enabled"
@change="(e) => updateWindow(w._idx, { end: e.target.value })"
/>
<button
class="text-[0.6rem] text-red-500 hover:text-red-700 bg-transparent border-0 cursor-pointer p-0 leading-tight"
:disabled="!api.settings.value.enabled"
@click="removeWindow(w._idx)"
>
<i class="pi pi-times text-[0.55rem]" /> remover
</button>
</div>
<button
class="text-[0.65rem] text-[var(--text-color-secondary)] hover:text-[var(--primary-color)] bg-transparent border border-dashed border-[var(--surface-border)] rounded py-0.5 cursor-pointer"
:disabled="!api.settings.value.enabled"
@click="addWindow(d.dow)"
>
+ adicionar
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Info sobre dependências -->
<div class="rounded-md border border-sky-500/25 bg-sky-500/5 p-3 flex items-start gap-2 text-xs text-[var(--text-color)]">
<i class="pi pi-info-circle text-sky-500 mt-0.5" />
<div>
<strong>Pré-requisito:</strong> o WhatsApp do tenant precisa estar conectado em
<RouterLink :to="whatsappPath" class="text-[var(--primary-color)] underline">{{ inMelissa ? 'Canal WhatsApp' : 'Configurações → WhatsApp' }}</RouterLink>.
O auto-reply é enviado via o mesmo canal.
<br/>
Todas as datas/horas são tratadas no fuso <strong>America/Sao_Paulo</strong>.
</div>
</div>
</div>
</template>