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>
This commit is contained in:
Leonardo
2026-05-07 23:48:18 -03:00
parent cc7841bd1f
commit 9966b5f175
60 changed files with 4785 additions and 713 deletions
+25 -14
View File
@@ -16,7 +16,7 @@
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -25,9 +25,16 @@ import { downloadExtratoCSV, downloadExtratoPDF, formatters } from '@/utils/addo
import { downloadExcel } from '@/utils/excelExport';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const tenantStore = useTenantStore();
// "Voltar" preserva o layout (Melissa vs /configuracoes).
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
function goBack() {
router.push(inMelissa.value ? '/melissa/cfg-recursos-extras' : '/configuracoes/recursos-extras');
}
const { rows, balances, filters, dateRange, loading, summary, load, loadBalances } = useAddonExtrato();
const tenantName = ref('');
@@ -193,7 +200,7 @@ watch(
<Card>
<template #title>
<div class="flex items-center gap-2">
<Button icon="pi pi-arrow-left" text rounded severity="secondary" class="!w-8 !h-8" @click="router.push({ name: 'ConfiguracoesRecursosExtras' })" v-tooltip.bottom="'Voltar'" />
<Button icon="pi pi-arrow-left" text rounded severity="secondary" class="!w-8 !h-8" @click="goBack" v-tooltip.bottom="'Voltar'" />
<i class="pi pi-list text-xl" />
Extrato de Recursos Extras
</div>
@@ -205,27 +212,27 @@ watch(
<Card>
<template #content>
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
<div class="md:col-span-3">
<div :class="inMelissa ? 'md:col-span-6' : 'md:col-span-3'">
<label class="text-xs font-medium text-surface-500 mb-1 block">Período</label>
<Select v-model="filters.periodPreset" :options="periodOptions" optionLabel="label" optionValue="value" class="w-full" @change="onFilterChange" />
</div>
<div v-if="filters.periodPreset === 'custom'" class="md:col-span-4">
<div v-if="filters.periodPreset === 'custom'" :class="inMelissa ? 'md:col-span-6' : 'md:col-span-4'">
<label class="text-xs font-medium text-surface-500 mb-1 block">Intervalo personalizado</label>
<DatePicker v-model="filters.customRange" selectionMode="range" dateFormat="dd/mm/yy" :manualInput="false" showIcon class="w-full" @update:model-value="onFilterChange" />
</div>
<div class="md:col-span-3">
<div :class="inMelissa ? 'md:col-span-6' : 'md:col-span-3'">
<label class="text-xs font-medium text-surface-500 mb-1 block">Tipo de recurso</label>
<MultiSelect v-model="filters.addonTypes" :options="addonTypeOptions" optionLabel="label" optionValue="value" placeholder="Todos" class="w-full" display="chip" :maxSelectedLabels="2" @change="onFilterChange" />
</div>
<div class="md:col-span-3">
<div :class="inMelissa ? 'md:col-span-6' : 'md:col-span-3'">
<label class="text-xs font-medium text-surface-500 mb-1 block">Tipo de movimento</label>
<MultiSelect v-model="filters.movementTypes" :options="movementOptions" optionLabel="label" optionValue="value" placeholder="Todos" class="w-full" display="chip" :maxSelectedLabels="2" @change="onFilterChange" />
</div>
<div class="md:col-span-6">
<div :class="inMelissa ? 'md:col-span-6' : 'md:col-span-12'">
<label class="text-xs font-medium text-surface-500 mb-1 block">Buscar por referência / descrição / método</label>
<IconField>
<InputIcon class="pi pi-search" />
@@ -233,18 +240,20 @@ watch(
</IconField>
</div>
<div class="md:col-span-6 flex items-end gap-2">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined @click="reload" :loading="loading" />
<Button label="Exportar CSV" icon="pi pi-file" severity="secondary" @click="onExportCSV" :disabled="!rows.length" />
<Button label="Exportar Excel" icon="pi pi-file-excel" severity="secondary" @click="onExportExcel" :disabled="!rows.length" />
<Button label="Exportar PDF" icon="pi pi-file-pdf" severity="secondary" @click="onExportPDF" :disabled="!rows.length" :loading="exportingPdf" />
<div class="md:col-span-12 flex items-end gap-2 flex-wrap md:flex-nowrap">
<Button class="md:flex-1" label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined @click="reload" :loading="loading" />
<Button class="md:flex-1" label="Exportar CSV" icon="pi pi-file" severity="secondary" @click="onExportCSV" :disabled="!rows.length" />
<Button class="md:flex-1" label="Exportar Excel" icon="pi pi-file-excel" severity="secondary" @click="onExportExcel" :disabled="!rows.length" />
<Button class="md:flex-1" label="Exportar PDF" icon="pi pi-file-pdf" severity="secondary" @click="onExportPDF" :disabled="!rows.length" :loading="exportingPdf" />
</div>
</div>
</template>
</Card>
<!-- Resumo -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Resumo: em /melissa teleporta pro drawer (#cfg-page-side) empilhado em
1 coluna; em /configuracoes fica inline em 4 colunas (md+). -->
<Teleport to="#cfg-page-side" :disabled="!inMelissa" defer>
<div :class="inMelissa ? 'flex flex-col gap-3' : 'grid grid-cols-2 md:grid-cols-4 gap-3'">
<Card>
<template #content>
<div class="flex flex-col">
@@ -278,6 +287,7 @@ watch(
</template>
</Card>
</div>
</Teleport>
<!-- Tabela -->
<Card>
@@ -293,6 +303,7 @@ watch(
sortField="created_at"
:sortOrder="-1"
emptyMessage="Nenhuma transação encontrada no período. Ajuste os filtros ou selecione um intervalo mais amplo."
scrollable
>
<Column field="created_at" header="Data" style="min-width: 150px" sortable>
<template #body="{ data }">{{ formatters.fmtDate(data.created_at) }}</template>
@@ -17,7 +17,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { RouterLink, useRoute } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
@@ -25,10 +25,15 @@ import { useToast } from 'primevue/usetoast';
import AgendadorPreview from '@/components/agendador/AgendadorPreview.vue';
import Editor from 'primevue/editor';
const route = useRoute();
const toast = useToast();
const tenantStore = useTenantStore();
const entitlements = useEntitlementsStore();
// Em /melissa o "Pagamento" vive em /melissa/pagamento. Preserva o layout.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const pagamentoPath = computed(() => (inMelissa.value ? '/melissa/pagamento' : '/configuracoes/pagamento'));
const hasAgendador = computed(() => entitlements.can('agendador.online'));
const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado'));
@@ -900,11 +905,11 @@ onMounted(load);
<Divider class="my-0" />
<div class="agd-group-title">Formas de pagamento aceitas</div>
<div class="px-1 flex flex-col gap-3">
<p class="text-surface-400 m-0">Selecione quais formas serão exibidas ao paciente. Configure os dados em <RouterLink to="/configuracoes/pagamento" class="underline">Configurações Pagamento</RouterLink>.</p>
<p class="text-surface-400 m-0">Selecione quais formas serão exibidas ao paciente. Configure os dados em <RouterLink :to="pagamentoPath" class="underline">{{ inMelissa ? 'Pagamento' : 'Configurações Pagamento' }}</RouterLink>.</p>
<div v-if="!algumMetodoConfigurado" class="rounded-[6px] border border-orange-200 bg-orange-50 dark:bg-orange-900/20 p-3 text-orange-700 dark:text-orange-300">
<i class="pi pi-exclamation-triangle mr-1" />
Nenhuma forma de pagamento configurada ainda.
<RouterLink to="/configuracoes/pagamento" class="underline font-medium ml-1">Configurar agora</RouterLink>
<RouterLink :to="pagamentoPath" class="underline font-medium ml-1">Configurar agora</RouterLink>
</div>
<div class="flex flex-col gap-2">
<label
@@ -938,7 +943,7 @@ onMounted(load);
<InputText v-model="cfg.pix_chave" :placeholder="paymentSettings.pix_chave ? `Usando: ${paymentSettings.pix_chave}` : 'CPF, e-mail, telefone ou chave aleatória'" class="w-full" />
<span v-if="!cfg.pix_chave && paymentSettings.pix_chave" class="cfg-hint">
Deixe vazio para usar a chave de
<RouterLink to="/configuracoes/pagamento" class="underline">Formas de Pagamento</RouterLink>
<RouterLink :to="pagamentoPath" class="underline">Formas de Pagamento</RouterLink>
({{ paymentSettings.pix_chave }}).
</span>
</div>
@@ -8,14 +8,25 @@
-->
<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' },
@@ -298,9 +309,9 @@ onBeforeUnmount(() => { if (_tick) clearInterval(_tick); });
<!-- 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 <a href="/configuracoes/agenda" class="text-[var(--primary-color)] underline">Configurações Agenda</a>.
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 grid-cols-7 gap-1">
<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">
@@ -341,7 +352,10 @@ onBeforeUnmount(() => { if (_tick) clearInterval(_tick); });
/>
</div>
<div class="grid grid-cols-7 gap-1.5 border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)] p-2">
<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">
@@ -390,7 +404,7 @@ onBeforeUnmount(() => { if (_tick) clearInterval(_tick); });
<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
<a href="/configuracoes/whatsapp" class="text-[var(--primary-color)] underline">Configurações WhatsApp</a>.
<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>.
@@ -9,15 +9,26 @@
-->
<script setup>
import { ref, computed, onMounted, watch, reactive } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useTenantStore } from '@/stores/tenantStore';
import { useWhatsappCredits } from '@/composables/useWhatsappCredits';
import { isValidCPF, fmtCPF, isValidCNPJ, fmtCNPJ } from '@/utils/validators';
const route = useRoute();
const toast = useToast();
const tenantStore = useTenantStore();
const api = useWhatsappCredits();
// Em /melissa o card vive numa coluna mais estreita — limita a loja de
// pacotes a 2 por linha (1 no mobile). /configuracoes mantem 4 por linha.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const pkgGridClass = computed(() => (
inMelissa.value
? 'grid grid-cols-1 md:grid-cols-2 gap-3'
: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3'
));
// Dialog de confirmação (coleta CPF/CNPJ antes de criar a cobrança)
const confirmDlg = reactive({
open: false,
@@ -233,7 +244,7 @@ watch(() => tenantStore.activeTenantId, () => { api.loadAll(); });
<span class="text-sm font-bold uppercase tracking-wide">Comprar créditos</span>
</div>
<div v-if="api.loading.value && !api.packages.value.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div v-if="api.loading.value && !api.packages.value.length" :class="pkgGridClass">
<Skeleton v-for="n in 4" :key="n" height="12rem" class="rounded-md" />
</div>
@@ -241,7 +252,7 @@ watch(() => tenantStore.activeTenantId, () => { api.loadAll(); });
Nenhum pacote disponível no momento. Contate o suporte.
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div v-else :class="pkgGridClass">
<div
v-for="pkg in api.packages.value"
:key="pkg.id"
@@ -16,7 +16,7 @@
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue';
@@ -27,6 +27,14 @@ import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const route = useRoute();
// Em /melissa o "Minha Empresa" foi unificado com /melissa/negocio (mesma
// tabela company_profiles). Preserva o layout em que a pagina foi aberta.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
function goEmpresa() {
router.push(inMelissa.value ? '/melissa/negocio' : '/configuracoes/empresa');
}
// ── Contexto ──────────────────────────────────────────────────
const tenantId = ref(null);
@@ -365,7 +373,7 @@ onMounted(async () => {
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-image text-[var(--text-color-secondary)] text-sm opacity-60" />
<span class="text-sm flex-1 text-[var(--text-color-secondary)]"> Para trocar sua logo, acesse <strong>Minha Empresa</strong>. </span>
<Button label="Minha Empresa" icon="pi pi-building" size="small" severity="secondary" outlined class="shrink-0" @click="router.push('/configuracoes/empresa')" />
<Button :label="inMelissa ? 'Meu Negócio' : 'Minha Empresa'" icon="pi pi-building" size="small" severity="secondary" outlined class="shrink-0" @click="goEmpresa" />
</div>
<!-- HEADER -->
@@ -16,7 +16,7 @@
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -24,6 +24,25 @@ import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const tenantStore = useTenantStore();
const router = useRouter();
const route = useRoute();
// Em /melissa o card vive numa coluna mais estreita — limita os pacotes
// a 2/linha (1 no mobile) e os "Em breve" a 1/linha. /configuracoes mantem o original.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const productsGridClass = computed(() => (
inMelissa.value
? 'grid grid-cols-1 md:grid-cols-2 gap-4 mb-4'
: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4'
));
const futureGridClass = computed(() => (
inMelissa.value
? 'grid grid-cols-1 gap-4'
: 'grid grid-cols-1 md:grid-cols-3 gap-4'
));
function goExtrato() {
router.push(inMelissa.value ? '/melissa/cfg-recursos-extras-extrato' : '/configuracoes/recursos-extras/extrato');
}
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
@@ -145,7 +164,7 @@ onMounted(async () => {
<i class="pi pi-box text-xl" />
Recursos Extras
</div>
<Button label="Ver extrato" icon="pi pi-list" severity="secondary" outlined size="small" @click="router.push({ name: 'ConfiguracoesRecursosExtrasExtrato' })" />
<Button label="Ver extrato" icon="pi pi-list" severity="secondary" outlined size="small" @click="goExtrato" />
</div>
</template>
<template #subtitle>Amplie as funcionalidades da sua clínica com recursos adicionais.</template>
@@ -164,7 +183,7 @@ onMounted(async () => {
<Tag v-else value="Sem créditos" severity="secondary" class="ml-2" />
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div :class="productsGridClass">
<Card v-for="product in items" :key="product.id" class="shadow-sm hover:shadow-md transition-shadow">
<template #title>
<div class="flex items-center gap-2 text-base">
@@ -195,7 +214,7 @@ onMounted(async () => {
Em breve
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div :class="futureGridClass">
<Card v-for="addon in futureAddons" :key="addon.type" class="opacity-60">
<template #title>
<div class="flex items-center gap-2 text-base">
@@ -18,15 +18,19 @@
import { ref, computed, onMounted, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const route = useRoute();
const tenantStore = useTenantStore();
// Preserva o layout em que a pagina esta aberta (Melissa vs /configuracoes).
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
const tenantId = ref(null);
@@ -302,7 +306,7 @@ function formatDate(iso) {
}
function goToRecursosExtras() {
router.push('/configuracoes/recursos-extras');
router.push(inMelissa.value ? '/melissa/cfg-recursos-extras' : '/configuracoes/recursos-extras');
}
// ── Init ──────────────────────────────────────────────────────
@@ -16,16 +16,22 @@
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useTwilioWhatsappStore } from '@/stores/twilioWhatsappStore';
import { useTenantStore } from '@/stores/tenantStore';
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
const store = useTwilioWhatsappStore();
const tenant = useTenantStore();
// "Trocar canal" preserva o layout (Melissa vs /configuracoes).
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const chooserPath = computed(() => (inMelissa.value ? '/melissa/cfg-wa' : '/configuracoes/whatsapp'));
// ── Computed ───────────────────────────────────────────────────────────────
const tenantId = computed(() => tenant.tenantId);
@@ -171,7 +177,7 @@ onMounted(async () => {
<template>
<Teleport to="#cfg-page-actions" defer>
<router-link to="/configuracoes/whatsapp">
<router-link :to="chooserPath">
<Button label="Trocar canal" icon="pi pi-arrow-left" severity="secondary" outlined size="small" class="rounded-full" />
</router-link>
</Teleport>
@@ -7,17 +7,25 @@
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// Detecta se o chooser foi aberto dentro do layout Melissa pra rotear o
// setup pras paginas nativas (cfg-wa-pessoal/cfg-wa-oficial) em vez das
// paginas de /configuracoes (que usam o layout antigo).
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const pessoalPath = computed(() => (inMelissa.value ? '/melissa/cfg-wa-pessoal' : '/configuracoes/whatsapp-pessoal'));
const oficialPath = computed(() => (inMelissa.value ? '/melissa/cfg-wa-oficial' : '/configuracoes/whatsapp-oficial'));
const loading = ref(true);
const switching = ref(false);
const activeChannel = ref(null); // row de notification_channels (provider, is_active, etc) ou null
@@ -114,8 +122,8 @@ async function reactivateProvider(provider) {
}
function goSetup(provider) {
if (provider === 'evolution') router.push('/configuracoes/whatsapp-pessoal');
else if (provider === 'twilio') router.push('/configuracoes/whatsapp-oficial');
if (provider === 'evolution') router.push(pessoalPath.value);
else if (provider === 'twilio') router.push(oficialPath.value);
}
async function handleChoose(provider) {
@@ -282,7 +290,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
Escolha outro canal abaixo pra trocar, ou clique em "Gerenciar" pra configurar o atual.
</div>
</div>
<router-link :to="activeProvider === 'twilio' ? '/configuracoes/whatsapp-oficial' : '/configuracoes/whatsapp-pessoal'">
<router-link :to="activeProvider === 'twilio' ? oficialPath : pessoalPath">
<Button
label="Gerenciar"
icon="pi pi-cog"
@@ -16,17 +16,23 @@
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// "Trocar canal" volta pro chooser. Mantem o usuario no layout em que esta:
// /melissa/cfg-wa se aberto via Melissa, /configuracoes/whatsapp caso contrario.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const chooserPath = computed(() => (inMelissa.value ? '/melissa/cfg-wa' : '/configuracoes/whatsapp'));
// Detecta area (admin clinica vs therapist) pra direcionar corretamente
function goConversas() {
const role = tenantStore.activeRole || tenantStore.role || '';
@@ -770,7 +776,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="#cfg-page-actions" defer>
<router-link to="/configuracoes/whatsapp">
<router-link :to="chooserPath">
<Button label="Trocar canal" icon="pi pi-arrow-left" severity="secondary" outlined size="small" class="rounded-full" />
</router-link>
</Teleport>
@@ -940,8 +946,8 @@ onBeforeUnmount(() => {
<TabPanel :value="1">
<div class="flex gap-4 pt-3 items-start">
<!-- Coluna esquerda: cards de templates (65%) -->
<div class="flex flex-col gap-3 min-w-0" style="flex: 0 0 65%;">
<!-- Coluna esquerda: cards de templates (65% no /configuracoes; 100% no /melissa pq a guia vai pro drawer) -->
<div class="flex flex-col gap-3 min-w-0" :style="inMelissa ? 'flex: 1;' : 'flex: 0 0 65%;'">
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
@@ -984,8 +990,16 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Coluna direita: guia de formatação (35%) -->
<div class="flex flex-col gap-3 sticky top-4" style="flex: 0 0 35%;">
<!-- Coluna direita: guia de formatacao.
Em /configuracoes: fica inline (35%).
Em /melissa: teleporta pro drawer (#cfg-page-side). -->
<Teleport to="#cfg-page-side" :disabled="!inMelissa" defer>
<div
v-show="!inMelissa || activeTab === 1"
class="flex flex-col gap-3"
:class="inMelissa ? '' : 'sticky top-4'"
:style="inMelissa ? '' : 'flex: 0 0 35%;'"
>
<div class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-4">
<div class="flex items-center gap-2">
<i class="pi pi-book text-[var(--primary-color)]" />
@@ -1083,6 +1097,7 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
</Teleport>
</div>
</TabPanel>