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
+3
View File
@@ -43,3 +43,6 @@ verde #00a884). Bug fix em MelissaPacientes: g.cor->g.color em 20 lugares
(repository devolve camelCase, template lia PT-BR e cores nao apareciam).
5 commits criados: 957e912, 6d9b36d, 269b531, 98f7252, 15103ed. Working tree
limpa. HANDOFF.md atualizado.
## [2026-05-06 17:30] session | Melissa drawers: footer pattern AppMenu
Touched: none
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -1262,14 +1262,28 @@ function insertEmoji(emoji) {
/* Light: bege esverdeado clássico do WA. Dark: cinza profundo tipo
wallpaper de modo escuro. Adapta via CSS variable `--p-content-background`
pra harmonizar com o tema do app. */
/* Background do chat estilo WhatsApp.
Light: cor bege classica (#efeae2) com a imagem doodle por cima usando
mix-blend multiply pra integrar.
Dark: cor #0b141a (mesma do WhatsApp Web dark) + camada escura translucida
por cima da imagem pra dimming. */
.cd-msgs {
background-color: color-mix(in srgb, var(--p-content-background) 85%, #efeae2);
background-image:
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--p-text-color) 4%, transparent) 1px, transparent 0);
background-size: 18px 18px;
background-color: #efeae2;
background-image: url('../../assets/whatsapp-bg.jpg');
background-size: 380px auto;
background-repeat: repeat;
background-blend-mode: multiply;
border-radius: 8px;
margin: 0 -2px;
}
:where(.p-dark, html.dark, [data-theme="dark"]) .cd-msgs,
.cd-msgs:where(.p-dark *, html.dark *, [data-theme="dark"] *) {
background-color: #0b141a;
background-image:
linear-gradient(rgba(11, 20, 26, 0.78), rgba(11, 20, 26, 0.78)),
url('../../assets/whatsapp-bg.jpg');
background-blend-mode: normal;
}
/* ─── Bolha (wrapper + content + meta) ─── */
.cd-bubble-wrap {
+118
View File
@@ -0,0 +1,118 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/JoditTextEditor.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
JoditTextEditor wrapper genérico do Jodit pra rich text simples
(bold/italic/underline + listas + link + eraser). Substitui o PrimeVue
<Editor> (Quill) em pontos onde a medição via JS do Quill colapsa
em flex layouts (ex: 3 editores empilhados num mesmo card).
Pra editor de e-mail com snippets de logo header/footer, use
JoditEmailEditor.vue (componente irmão, dedicado).
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { Jodit } from 'jodit/esm/index.js';
import 'jodit/es2021/jodit.min.css';
const props = defineProps({
modelValue: { type: String, default: '' },
minHeight: { type: Number, default: 120 },
placeholder: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue']);
const container = ref(null);
let jodit = null;
let _ignoreChange = false;
let _themeObserver = null;
function isDark() {
return document.documentElement.classList.contains('app-dark');
}
function buildConfig() {
return {
height: props.minHeight,
language: 'pt_br',
theme: isDark() ? 'dark' : 'default',
toolbarAdaptive: false,
toolbarSticky: false,
showCharsCounter: false,
showWordsCounter: false,
showXPathInStatusbar: false,
disablePlugins: ['about', 'stat'],
placeholder: props.placeholder,
// Toolbar enxuta — espelha o Quill que estava antes
// (bold/italic/underline + listas ordered/bullet + link).
buttons: [
'bold', 'italic', 'underline', '|',
'ul', 'ol', '|',
'link'
],
uploader: { insertImageAsBase64URI: false },
filebrowser: { ajax: { url: '' } }
};
}
function initJodit() {
if (jodit) {
jodit.destruct();
jodit = null;
}
jodit = Jodit.make(container.value, buildConfig());
if (props.modelValue) jodit.value = props.modelValue;
jodit.events.on('change', (content) => {
if (!_ignoreChange) emit('update:modelValue', content);
});
}
onMounted(() => {
initJodit();
// Re-cria o editor quando o tema muda (Jodit nao re-tema dinamicamente).
_themeObserver = new MutationObserver(() => {
const current = isDark() ? 'dark' : 'default';
if (jodit && jodit.o?.theme !== current) {
const saved = jodit.value;
initJodit();
if (saved) jodit.value = saved;
}
});
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
});
onBeforeUnmount(() => {
_themeObserver?.disconnect();
_themeObserver = null;
jodit?.destruct();
jodit = null;
});
watch(
() => props.modelValue,
(val) => {
if (!jodit) return;
if (jodit.value !== (val ?? '')) {
_ignoreChange = true;
jodit.value = val ?? '';
_ignoreChange = false;
}
}
);
</script>
<template>
<div ref="container" />
</template>
+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>
+4 -1
View File
@@ -124,8 +124,11 @@ function fecharDrawer() {
}
// ── Configurações da agenda (botão no header) ─────────────────
// MelissaAgenda esta sempre dentro de /melissa/*, entao roteamos pra
// pagina nativa Melissa equivalente (agenda-config) em vez de vazar
// pro layout antigo de /configuracoes/agenda.
function goSettings() {
router.push('/configuracoes/agenda');
router.push('/melissa/agenda-config');
}
// ── "Ações" (popover) — toolbar compacta em <xl ───────────────
+66 -24
View File
@@ -17,6 +17,7 @@
* SKIP: FullCalendar preview (visita a /melissa/agenda real).
*/
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -39,6 +40,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(true);
const hydrating = ref(true);
@@ -868,7 +874,15 @@ onBeforeUnmount(() => {
<div class="mac-body">
<Teleport to="#mac-mobile-drawer-target" :disabled="!isMobile">
<aside class="mac-side">
<div class="mac-side__scroll">
<button class="mac-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mac-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mac-side__scroll mac-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mac-side__scroll">
<!-- Card: Status do setup -->
<div class="mac-w mac-w--side">
<div class="mac-w__head">
@@ -1555,6 +1569,50 @@ onBeforeUnmount(() => {
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mac-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mac-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mac-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mac-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mac-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mac-cfg-btn > span { flex: 1; }
.mac-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mac-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mac-main {
flex: 1;
min-width: 0;
@@ -2195,23 +2253,6 @@ onBeforeUnmount(() => {
}
.mac-mobile-drawer.is-open { transform: translateX(0); }
.mac-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mac-mobile-drawer__scroll .mac-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mac-mobile-drawer__scroll .mac-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -2223,14 +2264,15 @@ onBeforeUnmount(() => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mac-mobile-drawer__scroll .mac-side__scroll::-webkit-scrollbar { width: 5px; }
.mac-mobile-drawer__scroll .mac-side__scroll::-webkit-scrollbar-thumb {
.mac-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mac-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mac-mobile-drawer__scroll .mac-w--side {
margin: 0;
flex-shrink: 0;
/* No mobile a .mac-side e teleportada pra dentro do drawer scroll. */
.mac-mobile-drawer__scroll .mac-side {
width: 100%;
border-right: none;
}
.mac-mobile-drawer__backdrop {
@@ -2249,7 +2291,7 @@ onBeforeUnmount(() => {
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mac-body { flex-direction: column; padding: 0; }
.mac-side { display: none; }
.mac-body > .mac-side { display: none; }
.mac-main { width: 100%; padding: 8px; }
.mac-main .mac-w {
height: auto;
+104 -130
View File
@@ -15,12 +15,17 @@
* agendador_configuracoes + bucket agendador + payment_settings).
*/
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import JoditTextEditor from '@/components/ui/JoditTextEditor.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
// InputText/Select/SelectButton/Textarea/RadioButton/Checkbox/ColorPicker/
// ToggleSwitch/Tag/Skeleton/Editor: auto via PrimeVueResolver
// ToggleSwitch/Tag/Skeleton: auto via PrimeVueResolver
// (PrimeVue <Editor> trocado por JoditTextEditor Quill colapsava em
// flex layouts com 3 editores empilhados, Jodit faz layout imperativo
// via JS sem depender de medicao do flex pai.)
const emit = defineEmits(['close']);
@@ -44,6 +49,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(true);
const ownerId = ref(null);
@@ -608,7 +618,15 @@ const summaryItems = computed(() => [
<div class="mag-body">
<Teleport to="#mag-mobile-drawer-target" :disabled="!isMobile">
<aside class="mag-side">
<div class="mag-side__scroll">
<button class="mag-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mag-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mag-side__scroll mag-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mag-side__scroll">
<!-- Card: Status -->
<div class="mag-w mag-w--side">
<div class="mag-w__head">
@@ -1287,49 +1305,21 @@ const summaryItems = computed(() => [
<div class="mag-field">
<label class="mag-label">Mensagem de boas-vindas</label>
<span class="mag-hint">Aparece após o paciente informar o nome.</span>
<Editor
<JoditTextEditor
v-model="cfg.mensagem_boas_vindas"
editorStyle="min-height: 100px; font-size: 0.92rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
</span>
</template>
</Editor>
:min-height="100"
/>
</div>
<div class="mag-divider" />
<div class="mag-field">
<label class="mag-label">Como se preparar para a sessão</label>
<span class="mag-hint">Aparece na confirmação. Dicas, instruções, etc.</span>
<Editor
<JoditTextEditor
v-model="cfg.texto_como_se_preparar"
editorStyle="min-height: 140px; font-size: 0.92rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
</span>
</template>
</Editor>
:min-height="140"
/>
</div>
<template v-if="cfg.exigir_aceite_lgpd">
@@ -1337,25 +1327,10 @@ const summaryItems = computed(() => [
<div class="mag-field">
<label class="mag-label">Termos de uso / Política de privacidade</label>
<span class="mag-hint">Aparece no checkbox de aceite.</span>
<Editor
<JoditTextEditor
v-model="cfg.texto_termos_lgpd"
editorStyle="min-height: 140px; font-size: 0.92rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
</span>
</template>
</Editor>
:min-height="140"
/>
</div>
</template>
@@ -1506,6 +1481,50 @@ const summaryItems = computed(() => [
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mag-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mag-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mag-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mag-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mag-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mag-cfg-btn > span { flex: 1; }
.mag-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mag-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mag-main {
flex: 1;
min-width: 0;
@@ -1525,44 +1544,23 @@ const summaryItems = computed(() => [
border-radius: 3px;
}
/* Desktop (>=1024): main em grid 2-col com Textos full-width.
Cards min-h 300 + max-h 100% + body scroll (lições aprendidas). */
/* Desktop (>=1024): cards empilhados verticalmente (1-col), cada um
com altura propria do conteudo. O grid 2-col anterior forcava cells
na mesma row a igualarem altura com align-self: start cards menores
ficavam no topo do cell e o espaco vazio embaixo dava aparencia de
"encavalado". 1-col com max-width centralizado eh mais legivel em
telas largas e respeita "100% do conteudo interno" pedido pelo user.
.mag-main ja eh display:flex flex-direction:column gap:12px no base
so adiciona limites de largura aqui. */
@media (min-width: 1024px) {
.mag-main {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
align-items: start;
align-content: start;
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
.mag-main > .mag-w {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mag-main > .mag-w--full { grid-column: 1 / -1; }
.mag-side > .mag-side__scroll > .mag-w--side {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mag-main > .mag-w > .mag-w__body,
.mag-side .mag-w--side > .mag-w__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-main > .mag-w > .mag-w__body::-webkit-scrollbar,
.mag-side .mag-w--side > .mag-w__body::-webkit-scrollbar { width: 5px; }
.mag-main > .mag-w > .mag-w__body::-webkit-scrollbar-thumb,
.mag-side .mag-w--side > .mag-w__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
height: auto;
flex-shrink: 0;
}
}
@@ -1862,6 +1860,12 @@ const summaryItems = computed(() => [
display: flex;
flex-direction: column;
gap: 6px;
/* flex-shrink: 0 em parent flex-col (.mag-w__body), garante que
fields nao colapsem. Originalmente adicionado pro PrimeVue Editor
(Quill) que media altura via JS e shrunk quando flex pai nao tinha
altura definida. Mantido apos troca pra Jodit por consistencia
(form fields nunca devem shrink em layout vertical). */
flex-shrink: 0;
}
.mag-label {
font-size: 0.78rem;
@@ -2127,23 +2131,9 @@ const summaryItems = computed(() => [
}
.mag-warn > i { font-size: 0.92rem; margin-top: 2px; flex-shrink: 0; }
/* ═══════ Editor (PrimeVue Quill) overrides ═══════ */
:deep(.p-editor-container) { background: var(--m-bg-medium); border-radius: 8px; }
:deep(.p-editor-toolbar) {
background: var(--m-bg-soft) !important;
border: 1px solid var(--m-border) !important;
border-bottom: none !important;
border-radius: 8px 8px 0 0 !important;
}
:deep(.p-editor-content) {
background: var(--m-bg-medium) !important;
border: 1px solid var(--m-border) !important;
border-radius: 0 0 8px 8px !important;
}
:deep(.p-editor-content .ql-editor) {
color: var(--m-text);
background: var(--m-bg-medium);
}
/* PrimeVue Editor (Quill) trocado por JoditTextEditor Jodit traz
CSS proprio (jodit/es2021/jodit.min.css importado no componente)
e suporta dark/light via theme prop. Sem override scoped aqui. */
/* ═══════ Mobile drawer ═══════ */
.mag-mobile-drawer {
@@ -2166,23 +2156,6 @@ const summaryItems = computed(() => [
}
.mag-mobile-drawer.is-open { transform: translateX(0); }
.mag-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mag-mobile-drawer__scroll .mag-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mag-mobile-drawer__scroll .mag-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -2194,14 +2167,15 @@ const summaryItems = computed(() => [
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-mobile-drawer__scroll .mag-side__scroll::-webkit-scrollbar { width: 5px; }
.mag-mobile-drawer__scroll .mag-side__scroll::-webkit-scrollbar-thumb {
.mag-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mag-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mag-mobile-drawer__scroll .mag-w--side {
margin: 0;
flex-shrink: 0;
/* No mobile a .mag-side e teleportada pra dentro do drawer scroll. */
.mag-mobile-drawer__scroll .mag-side {
width: 100%;
border-right: none;
}
.mag-mobile-drawer__backdrop {
@@ -2220,7 +2194,7 @@ const summaryItems = computed(() => [
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mag-body { flex-direction: column; padding: 0; }
.mag-side { display: none; }
.mag-body > .mag-side { display: none; }
.mag-main { width: 100%; padding: 8px; }
.mag-main .mag-w {
height: auto;
+67 -32
View File
@@ -14,6 +14,7 @@
* target=therapist + plan_prices + RPC change_subscription_plan).
*/
import { ref, computed, onMounted } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -35,6 +36,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(true);
const saving = ref(false);
@@ -354,7 +360,15 @@ onMounted(async () => {
<div class="map-body">
<Teleport to="#map-mobile-drawer-target" :disabled="!isMobile">
<aside class="map-side">
<div class="map-side__scroll">
<button class="map-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down map-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="map-side__scroll map-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="map-side__scroll">
<!-- Card: Plano atual -->
<div class="map-w map-w--side">
<div class="map-w__head">
@@ -427,7 +441,7 @@ onMounted(async () => {
</div>
</div>
<div class="map-side__footer">
<div v-if="!cfgOpen" class="map-side__footer">
<button class="map-btn map-btn--full" @click="emit('goto', 'plano')">
<i class="pi pi-arrow-left" />
<span>Voltar pro Meu Plano</span>
@@ -685,6 +699,50 @@ onMounted(async () => {
border-top: 1px solid var(--m-border);
}
/* Botao "Configuracoes" no topo da .map-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.map-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.map-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.map-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.map-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.map-cfg-btn > span { flex: 1; }
.map-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.map-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.map-main {
flex: 1;
min-width: 0;
@@ -1007,23 +1065,6 @@ onMounted(async () => {
}
.map-mobile-drawer.is-open { transform: translateX(0); }
.map-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.map-mobile-drawer__scroll .map-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.map-mobile-drawer__scroll .map-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1035,21 +1076,15 @@ onMounted(async () => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.map-mobile-drawer__scroll .map-side__scroll::-webkit-scrollbar { width: 5px; }
.map-mobile-drawer__scroll .map-side__scroll::-webkit-scrollbar-thumb {
.map-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.map-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.map-mobile-drawer__scroll .map-w--side {
margin: 0;
flex-shrink: 0;
}
.map-mobile-drawer__scroll .map-side__footer {
flex-shrink: 0;
margin: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
/* No mobile a .map-side e teleportada pra dentro do drawer scroll. */
.map-mobile-drawer__scroll .map-side {
width: 100%;
border-right: none;
}
.map-mobile-drawer__backdrop {
@@ -1094,7 +1129,7 @@ onMounted(async () => {
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.map-body { flex-direction: column; padding: 0; }
.map-side { display: none; }
.map-body > .map-side { display: none; }
.map-main { width: 100%; padding: 8px; }
.map-page__title > span:first-of-type { display: none; }
.map-page__title-icon { display: none; }
+66 -24
View File
@@ -15,6 +15,7 @@
* tabela agenda_bloqueios).
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
@@ -49,6 +50,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loadingB = ref(true);
const saving = ref(false);
@@ -412,7 +418,15 @@ onBeforeUnmount(() => {
<div class="mbq-body">
<Teleport to="#mbq-mobile-drawer-target" :disabled="!isMobile">
<aside class="mbq-side">
<div class="mbq-side__scroll">
<button class="mbq-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mbq-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mbq-side__scroll mbq-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mbq-side__scroll">
<!-- Card: Resumo -->
<div class="mbq-w mbq-w--side">
<div class="mbq-w__head">
@@ -984,6 +998,50 @@ onBeforeUnmount(() => {
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mbq-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mbq-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mbq-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mbq-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mbq-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mbq-cfg-btn > span { flex: 1; }
.mbq-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mbq-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mbq-main {
flex: 1;
min-width: 0;
@@ -1397,23 +1455,6 @@ onBeforeUnmount(() => {
}
.mbq-mobile-drawer.is-open { transform: translateX(0); }
.mbq-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mbq-mobile-drawer__scroll .mbq-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mbq-mobile-drawer__scroll .mbq-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1425,14 +1466,15 @@ onBeforeUnmount(() => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mbq-mobile-drawer__scroll .mbq-side__scroll::-webkit-scrollbar { width: 5px; }
.mbq-mobile-drawer__scroll .mbq-side__scroll::-webkit-scrollbar-thumb {
.mbq-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mbq-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mbq-mobile-drawer__scroll .mbq-w--side {
margin: 0;
flex-shrink: 0;
/* No mobile a .mbq-side e teleportada pra dentro do drawer scroll. */
.mbq-mobile-drawer__scroll .mbq-side {
width: 100%;
border-right: none;
}
.mbq-mobile-drawer__backdrop {
@@ -1451,7 +1493,7 @@ onBeforeUnmount(() => {
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mbq-body { flex-direction: column; padding: 0; }
.mbq-side { display: none; }
.mbq-body > .mbq-side { display: none; }
.mbq-main { width: 100%; padding: 8px; }
.mbq-main .mbq-w {
height: auto;
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const AuditoriaPage = defineAsyncComponent(
() => import('@/layout/configuracoes/AuditoriaPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-shield', label: 'LGPD', text: 'Art. 37 — registro obrigatório de operações de tratamento.' },
{ icon: 'pi pi-lock', label: 'Imutável', text: 'Não pode ser editado nem excluído (totalmente auditável).' },
{ icon: 'pi pi-download', label: 'Exportar', text: 'Gere relatórios em CSV ou JSON quando precisar prestar contas.' }
];
</script>
<template>
<MelissaConfigPage
title="Auditoria"
icon="pi pi-shield"
subtitle="Registro imutável de operações sensíveis (acessos, alterações, exclusões). Exigência LGPD."
faq-sub="LGPD Art. 37"
:faq="faq"
@close="emit('close')"
>
<AuditoriaPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesConversasAutoreplyPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesConversasAutoreplyPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-clock', label: 'Horário', text: 'Define a janela em que o auto-reply é acionado (fora dela).' },
{ icon: 'pi pi-pencil', label: 'Mensagem', text: 'O texto enviado automaticamente pra quem escreve fora do horário.' },
{ icon: 'pi pi-power-off', label: 'Ativar', text: 'Switch master liga ou desliga o recurso por completo.' }
];
</script>
<template>
<MelissaConfigPage
title="Auto-reply WhatsApp"
icon="pi pi-reply"
subtitle="Resposta automática enviada quando o paciente escreve fora do seu horário de atendimento."
faq-sub="Resposta fora do horário"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesConversasAutoreplyPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesConversasBotsPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesConversasBotsPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-user', label: 'Coleta', text: 'Pergunta nome, motivo e urgência antes do humano assumir.' },
{ icon: 'pi pi-arrow-right-arrow-left', label: 'Transição', text: 'Passa a conversa pro humano após a triagem inicial.' },
{ icon: 'pi pi-pencil', label: 'Editar', text: 'Customize as perguntas conforme sua prática clínica.' }
];
</script>
<template>
<MelissaConfigPage
title="Bot de triagem"
icon="pi pi-android"
subtitle="Bot coleta nome, motivo e contexto via WhatsApp antes do fluxo humano começar."
faq-sub="Triagem inicial"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesConversasBotsPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesConversasOptoutsPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesConversasOptoutsPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-shield', label: 'LGPD', text: 'Direito de oposição (Art. 18) registrado e auditável.' },
{ icon: 'pi pi-send', label: 'Bloqueia', text: 'Impede envios automáticos pro número opt-out.' },
{ icon: 'pi pi-comment', label: 'Manual', text: 'Recebimento e resposta manual continuam funcionando normalmente.' }
];
</script>
<template>
<MelissaConfigPage
title="Opt-outs (LGPD)"
icon="pi pi-ban"
subtitle="Números que pediram pra não receber mensagens. Direito de oposição garantido pela LGPD."
faq-sub="LGPD Art. 18"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesConversasOptoutsPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesConversasSlaPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesConversasSlaPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-clock', label: 'Tempo', text: 'Defina em minutos ou horas o limite pra responder.' },
{ icon: 'pi pi-bell', label: 'Alerta', text: 'Notificação acionada quando o SLA está prestes a estourar.' },
{ icon: 'pi pi-chart-bar', label: 'Histórico', text: 'Dashboard mostra o cumprimento do SLA ao longo do tempo.' }
];
</script>
<template>
<MelissaConfigPage
title="SLA de resposta"
icon="pi pi-stopwatch"
subtitle="Tempo máximo pra responder uma conversa. O sistema notifica quando o SLA está estourando."
faq-sub="Tempo de resposta"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesConversasSlaPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesConversasTagsPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesConversasTagsPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-palette', label: 'Cor', text: 'Cada tag tem cor pra identificar visualmente na lista.' },
{ icon: 'pi pi-mouse-pointer', label: 'Aplicar', text: 'Arraste a tag pra conversa ou use o menu de contexto.' },
{ icon: 'pi pi-filter', label: 'Filtrar', text: 'Liste apenas conversas com determinada tag no CRM.' }
];
</script>
<template>
<MelissaConfigPage
title="Tags de Conversa"
icon="pi pi-tag"
subtitle="Crie tags pra classificar threads no CRM (urgente, remarcação, financeiro…)."
faq-sub="Etiquetas custom"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesConversasTagsPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesCreditosWhatsappPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesCreditosWhatsappPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-shopping-cart', label: 'Pacotes', text: 'Compre antecipado pra pagar menos por mensagem.' },
{ icon: 'pi pi-wallet', label: 'Saldo', text: 'Veja em tempo real quantas mensagens ainda restam.' },
{ icon: 'pi pi-list', label: 'Extrato', text: 'Histórico de uso e recargas, exportável quando precisar.' }
];
</script>
<template>
<MelissaConfigPage
title="Créditos WhatsApp"
icon="pi pi-credit-card"
subtitle="Compre pacotes antecipados de WhatsApp. Veja saldo, extrato e histórico em tempo real."
faq-sub="Pacotes de mensagens"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesCreditosWhatsappPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesEmailTemplatesPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-code', label: 'Variáveis', text: '{{nome}}, {{data}}, {{horario}} são substituídas no envio.' },
{ icon: 'pi pi-palette', label: 'HTML', text: 'Formatação rica com cores, imagens e links.' },
{ icon: 'pi pi-refresh', label: 'Padrão', text: 'Volte ao template original AgenciaPSI quando quiser.' }
];
</script>
<template>
<MelissaConfigPage
title="Templates de E-mail"
icon="pi pi-envelope"
subtitle="Personalize os e-mails enviados aos pacientes (lembretes, confirmações, recibos)."
faq-sub="E-mails enviados"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesEmailTemplatesPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesLembretesSessaoPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesLembretesSessaoPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-clock', label: '24h antes', text: 'Lembrete da sessão do dia seguinte (lead-time longo).' },
{ icon: 'pi pi-stopwatch', label: '2h antes', text: 'Confirmação final pouco antes da sessão começar.' },
{ icon: 'pi pi-pencil', label: 'Texto', text: 'Customize o que enviar em cada janela de lembrete.' }
];
</script>
<template>
<MelissaConfigPage
title="Lembretes de Sessão"
icon="pi pi-bell"
subtitle="WhatsApp automático antes das sessões agendadas — reduz no-show significativamente."
faq-sub="Confirmação de sessão"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesLembretesSessaoPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesRecursosExtrasPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-plus-circle', label: 'Add-ons', text: 'Recursos opcionais que ampliam seu plano contratado.' },
{ icon: 'pi pi-credit-card', label: 'Cobrança', text: 'Cobrado à parte da mensalidade do plano principal.' },
{ icon: 'pi pi-times-circle', label: 'Cancelar', text: 'Desativação imediata, sem multa nem fidelidade.' }
];
</script>
<template>
<MelissaConfigPage
title="Recursos Extras"
icon="pi pi-box"
subtitle="Amplie as funcionalidades do seu plano com recursos adicionais cobrados à parte."
faq-sub="Add-ons"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesRecursosExtrasPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const AddonsExtratoPage = defineAsyncComponent(
() => import('@/layout/configuracoes/AddonsExtratoPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-filter', label: 'Período', text: 'Filtre por mês, últimos 90 dias ou intervalo personalizado.' },
{ icon: 'pi pi-tag', label: 'Tipo', text: 'Cruze por SMS, e-mail, servidor ou domínio.' },
{ icon: 'pi pi-download', label: 'Exportar', text: 'CSV, PDF ou Excel — imutável e auditável (LGPD).' }
];
</script>
<template>
<MelissaConfigPage
title="Extrato de Recursos Extras"
icon="pi pi-list"
subtitle="Histórico de débitos e créditos dos seus recursos extras (SMS, e-mail, etc). Exportável em CSV, PDF ou Excel."
faq-sub="Movimentações"
:faq="faq"
@close="emit('close')"
>
<AddonsExtratoPage />
</MelissaConfigPage>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesSmsPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesSmsPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-shield', label: 'Backup', text: 'Acionado quando WhatsApp falha ou não está configurado.' },
{ icon: 'pi pi-dollar', label: 'Custo', text: 'Cobrado por mensagem enviada — opere com pacotes.' },
{ icon: 'pi pi-info-circle', label: 'Limite', text: '160 caracteres por mensagem (limite da operadora).' }
];
</script>
<template>
<MelissaConfigPage
title="SMS"
icon="pi pi-comment"
subtitle="Gerencie créditos SMS e personalize as mensagens. Funciona como backup quando o WhatsApp falha."
faq-sub="Mensagens SMS"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesSmsPage />
</MelissaConfigPage>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesWhatsappChooserPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesWhatsappChooserPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-check-circle', label: 'Oficial', text: 'AgenciaPSI envia em nome da plataforma — maior taxa de entrega.' },
{ icon: 'pi pi-mobile', label: 'Pessoal', text: 'Você envia do seu próprio número, sob sua reputação.' },
{ icon: 'pi pi-history', label: 'Trocar', text: 'Histórico das conversas anteriores é mantido ao alternar.' }
];
</script>
<template>
<MelissaConfigPage
title="Canal WhatsApp"
icon="pi pi-whatsapp"
subtitle="Escolha o canal de envio: oficial AgenciaPSI (pela plataforma) ou pessoal (seu próprio número)."
faq-sub="Canal de envio"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesWhatsappChooserPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesTwilioWhatsappPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesTwilioWhatsappPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-verified', label: 'Número novo', text: 'AgenciaPSI provisiona um número 100% seu, via API oficial da Meta.' },
{ icon: 'pi pi-check-circle', label: 'Entregabilidade', text: 'Bloqueios são raros — o canal oficial tem prioridade e maior alcance.' },
{ icon: 'pi pi-credit-card', label: 'Créditos', text: 'Cada mensagem consome créditos do seu pacote pré-pago.' }
];
</script>
<template>
<MelissaConfigPage
title="WhatsApp Oficial AgenciaPSI"
icon="pi pi-verified"
subtitle="Número provisionado pela AgenciaPSI via Twilio + API oficial Meta. Maior entregabilidade, custo por mensagem."
faq-sub="Canal oficial"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesTwilioWhatsappPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesWhatsappPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesWhatsappPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-qrcode', label: 'QR Code', text: 'Pareie com seu celular escaneando um QR code (igual WhatsApp Web).' },
{ icon: 'pi pi-mobile', label: 'Seu número', text: 'A reputação do número é sua — bloqueios e desbloqueios são por sua conta.' },
{ icon: 'pi pi-wifi', label: 'Conexão', text: 'Depende do celular online. Se desconectar, mensagens não são entregues.' }
];
</script>
<template>
<MelissaConfigPage
title="WhatsApp Pessoal"
icon="pi pi-mobile"
subtitle="Conecte seu próprio número via QR code. Mensagens enviadas e recebidas pelo seu celular."
faq-sub="Conexão via QR"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesWhatsappPage />
</MelissaConfigPage>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { defineAsyncComponent } from 'vue';
import MelissaConfigPage from './MelissaConfigPage.vue';
const ConfiguracoesWhatsappTemplatesPage = defineAsyncComponent(
() => import('@/layout/configuracoes/ConfiguracoesWhatsappTemplatesPage.vue')
);
const emit = defineEmits(['close']);
const faq = [
{ icon: 'pi pi-code', label: 'Variáveis', text: '{{nome}}, {{data}}, {{horario}} são substituídas no envio.' },
{ icon: 'pi pi-refresh', label: 'Padrão', text: 'Volte ao texto AgenciaPSI a qualquer momento.' },
{ icon: 'pi pi-shield', label: 'Aprovação', text: 'Templates oficiais Meta passam por validação antes do uso.' }
];
</script>
<template>
<MelissaConfigPage
title="Templates WhatsApp"
icon="pi pi-file-edit"
subtitle="Personalize os textos enviados pelo WhatsApp ou volte ao padrão da plataforma."
faq-sub="Mensagens enviadas"
:faq="faq"
@close="emit('close')"
>
<ConfiguracoesWhatsappTemplatesPage />
</MelissaConfigPage>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup>
/*
* MelissaConfigList Lista flat das paginas de config Melissa.
*
* Renderiza inline (sem overlay/popover/teleport). Pra usar como
* "outro estado" da sidebar alterna com os cards contextuais via
* v-if no parent. Click num item navega pra /melissa/<slug> e
* emite 'select' (parent fecha sua propria lista).
*
* Lista flat agrupada por section header (uppercase pequeno) todos
* os itens visiveis ao mesmo tempo, sem accordion.
*
* Uso:
* const cfgOpen = ref(false);
* <button @click="cfgOpen = !cfgOpen">Configurações</button>
* <div v-if="cfgOpen"><MelissaConfigList @select="cfgOpen = false" /></div>
* <div v-else>...cards...</div>
*/
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { MELISSA_CONFIG_GRUPOS } from './composables/melissaConfigGrupos.js';
const emit = defineEmits(['select']);
const router = useRouter();
const route = useRoute();
const activeSlug = computed(() => String(route.params?.secao || ''));
function selecionar(item) {
emit('select', item);
if (route.params?.secao !== item.key) {
router.push({ name: 'Melissa', params: { secao: item.key } });
}
}
const isActive = (key) => activeSlug.value === key;
</script>
<template>
<div class="mcl">
<template v-for="g in MELISSA_CONFIG_GRUPOS" :key="g.key">
<div class="mcl__section">{{ g.label }}</div>
<button
v-for="item in g.items"
:key="item.key"
type="button"
class="mcl__item"
:class="{ 'is-active': isActive(item.key) }"
@click="selecionar(item)"
>
<i :class="item.icon" class="mcl__item-icon" />
<span class="mcl__item-label">{{ item.label }}</span>
</button>
</template>
</div>
</template>
<style scoped>
.mcl {
display: flex;
flex-direction: column;
gap: 1px;
}
.mcl__section {
padding: 12px 8px 4px;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--m-text-muted);
}
.mcl__section:first-child { padding-top: 0; }
.mcl__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 7px;
color: var(--m-text);
cursor: pointer;
text-align: left;
font-family: inherit;
font-size: 0.84rem;
transition: background-color 120ms ease, color 120ms ease;
}
.mcl__item:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, transparent);
color: var(--p-primary-color);
}
.mcl__item:hover .mcl__item-icon { color: var(--p-primary-color); }
.mcl__item.is-active {
background: color-mix(in srgb, var(--p-primary-color) 16%, transparent);
color: var(--p-primary-color);
font-weight: 600;
}
.mcl__item.is-active .mcl__item-icon { color: var(--p-primary-color); }
.mcl__item-icon {
width: 16px;
text-align: center;
color: var(--m-text-muted);
font-size: 0.85rem;
flex-shrink: 0;
}
.mcl__item-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+404
View File
@@ -0,0 +1,404 @@
<script setup>
/*
* MelissaConfigPage Chrome compartilhado pras paginas nativas de
* configuracao Melissa (cfg-wa, cfg-lembretes, etc).
*
* Padroniza header, drawer mobile, sidebar (Configuracoes + FAQ) e main.
* Cada pagina concreta passa props (titulo/icone/subtitulo/faq) e o
* conteudo via slot default.
*/
import { ref, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
subtitle: { type: String, default: '' },
faqTitle: { type: String, default: 'Como funciona' },
faqSub: { type: String, default: '' },
faq: { type: Array, default: () => [] }
});
const emit = defineEmits(['close']);
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<Transition name="mcp-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mcp-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mcp-mobile-drawer-target" class="mcp-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mcp-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mcp-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mcp-page">
<header class="mcp-page__head">
<button
class="mcp-menu-btn mcp-menu-btn--mobile-only"
v-tooltip.bottom="'Configurações'"
aria-label="Abrir menu de configurações"
@click="toggleDrawer"
>
<i class="pi pi-bars" aria-hidden="true" />
<span>Menu</span>
</button>
<div class="mcp-page__title">
<i :class="icon" class="mcp-page__title-icon" aria-hidden="true" />
<span>{{ title }}</span>
</div>
<div class="mcp-page__actions">
<!-- Target dos <Teleport to="#cfg-page-actions"> dos ConfiguracoesXxxPage embutidos -->
<div id="cfg-page-actions" class="mcp-page__actions-slot"></div>
<button
class="mcp-close"
v-tooltip.bottom="'Voltar (Esc)'"
aria-label="Voltar (Esc)"
@click="emit('close')"
>
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
</header>
<div v-if="subtitle" class="mcp-subheader" role="note">
<i class="pi pi-info-circle mcp-subheader__icon" aria-hidden="true" />
<span class="mcp-subheader__text">{{ subtitle }}</span>
</div>
<div class="mcp-body">
<Teleport to="#mcp-mobile-drawer-target" :disabled="!isMobile">
<aside class="mcp-side">
<button
class="mcp-cfg-btn"
:class="{ 'is-open': cfgOpen }"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" aria-hidden="true" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mcp-cfg-btn__chev" aria-hidden="true" />
</button>
<div v-if="cfgOpen" class="mcp-side__scroll mcp-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mcp-side__scroll">
<div v-if="faq.length" class="mcp-w mcp-w--side">
<div class="mcp-w__head">
<div class="mcp-w__icon"><i class="pi pi-question-circle" aria-hidden="true" /></div>
<div class="mcp-w__title">
<div class="mcp-w__title-text">{{ faqTitle }}</div>
<div v-if="faqSub" class="mcp-w__sub">{{ faqSub }}</div>
</div>
</div>
<div class="mcp-w__body">
<ul class="mcp-faq">
<li v-for="(item, i) in faq" :key="i" class="mcp-faq__item">
<i :class="item.icon" aria-hidden="true" />
<span><strong>{{ item.label }}:</strong> {{ item.text }}</span>
</li>
</ul>
</div>
</div>
<slot name="side" />
<!-- Target dos <Teleport to="#cfg-page-side"> das pages embarcadas
(ex: guia de formatacao do WhatsappPage na aba templates) -->
<div id="cfg-page-side" class="mcp-side-target"></div>
</div>
</aside>
</Teleport>
<div class="mcp-main">
<Suspense>
<template #default>
<slot />
</template>
<template #fallback>
<div class="mcp-loading">
<i class="pi pi-spin pi-spinner" aria-hidden="true" />
<span>Carregando</span>
</div>
</template>
</Suspense>
</div>
</div>
</section>
</template>
<style scoped>
.mcp-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex; flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mcp-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mcp-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mcp-page__head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0; gap: 10px;
}
.mcp-page__title {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 10px;
font-size: 1rem; font-weight: 700; color: var(--m-text);
flex-wrap: wrap;
}
.mcp-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mcp-page__actions { display: flex; align-items: center; gap: 6px; }
.mcp-page__actions-slot { display: flex; align-items: center; gap: 6px; }
.mcp-page__actions-slot:empty { display: none; }
.mcp-side-target { display: flex; flex-direction: column; gap: 12px; }
.mcp-side-target:empty { display: none; }
.mcp-close {
width: 32px; height: 32px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mcp-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mcp-menu-btn {
display: none;
align-items: center; gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem; font-weight: 600;
flex-shrink: 0;
}
.mcp-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mcp-subheader {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mcp-subheader__icon { color: var(--p-primary-color); font-size: 0.85rem; margin-top: 2px; flex-shrink: 0; }
.mcp-body { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.mcp-side {
width: 320px; flex-shrink: 0;
display: flex; flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mcp-side__scroll {
flex: 1; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 12px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcp-side__scroll::-webkit-scrollbar { width: 5px; }
.mcp-side__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mcp-cfg-btn {
display: flex; align-items: center; gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem; font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mcp-cfg-btn:hover { background: var(--m-bg-soft-hover); border-color: var(--m-border-strong); }
.mcp-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mcp-cfg-btn > i:first-child { color: var(--p-primary-color); font-size: 0.92rem; }
.mcp-cfg-btn > span { flex: 1; }
.mcp-cfg-btn__chev { color: var(--m-text-muted); font-size: 0.7rem; }
.mcp-side__scroll--cfg { padding: 8px; gap: 0; }
.mcp-main {
flex: 1; min-width: 0; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 16px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcp-main::-webkit-scrollbar { width: 5px; }
.mcp-main::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mcp-loading {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 32px;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mcp-loading > i { color: var(--p-primary-color); font-size: 1.2rem; }
@media (min-width: 1024px) {
.mcp-page { right: max(6px, min(50%, calc(100% - 1006px))); }
.mcp-side > .mcp-side__scroll > .mcp-w--side {
flex-shrink: 0;
height: auto;
display: flex;
flex-direction: column;
}
}
.mcp-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex; flex-direction: column;
}
.mcp-w--side { background: var(--m-bg-medium); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16); }
.mcp-w__head {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mcp-w__icon {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mcp-w__icon > i { font-size: 0.95rem; }
.mcp-w__title { flex: 1; min-width: 0; }
.mcp-w__title-text { font-size: 0.92rem; font-weight: 700; color: var(--m-text); line-height: 1.2; }
.mcp-w__sub { font-size: 0.74rem; color: var(--m-text-muted); margin-top: 2px; line-height: 1.3; }
.mcp-w__body { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.mcp-faq {
display: flex; flex-direction: column; gap: 10px;
list-style: none; margin: 0; padding: 0;
}
.mcp-faq__item {
display: flex; gap: 10px;
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mcp-faq__item > i { color: var(--p-primary-color); font-size: 0.78rem; margin-top: 3px; flex-shrink: 0; }
.mcp-faq__item strong { color: var(--m-text); font-weight: 600; }
.mcp-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh; height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex; flex-direction: column;
}
.mcp-mobile-drawer.is-open { transform: translateX(0); }
.mcp-mobile-drawer__scroll {
flex: 1; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 12px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcp-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mcp-mobile-drawer__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mcp-mobile-drawer__scroll .mcp-side { width: 100%; border-right: none; }
.mcp-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mcp-drawer-fade-enter-active,
.mcp-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mcp-drawer-fade-enter-from,
.mcp-drawer-fade-leave-to { opacity: 0; }
@media (max-width: 1023px) {
.mcp-body { flex-direction: column; padding: 0; }
.mcp-body > .mcp-side { display: none; }
.mcp-main { width: 100%; padding: 8px; }
.mcp-page__title > span:first-of-type { display: none; }
.mcp-page__title-icon { display: none; }
.mcp-menu-btn--mobile-only { display: inline-flex; }
}
</style>
+114 -80
View File
@@ -22,8 +22,9 @@
* ainda é responsabilidade do MelissaLayout (watchers existentes).
*/
import { computed, defineAsyncComponent, inject, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
// useRouter/useRoute removidos navegacao migrou pro MelissaConfigSidebar
import { TOQUES } from './melissaToques';
import MelissaConfigList from './MelissaConfigList.vue';
const props = defineProps({
// Key da rota /melissa/:secao usada pra pré-selecionar uma seção interna
@@ -48,9 +49,7 @@ const DEPRECATED_ALIASES = {
relogio: 'aparencia',
cronometro: 'aparencia'
};
const SECAO_ALIASES = Object.fromEntries(
Object.entries(ROUTE_ALIASES).map(([slug, key]) => [key, slug])
);
// SECAO_ALIASES removido era usado por secaoToRota (tambem removido).
function rotaToSecao(rota) {
if (!rota) return 'aparencia';
@@ -61,10 +60,8 @@ function rotaToSecao(rota) {
if (COMPONENT_MAP[r]) return r;
return 'aparencia';
}
function secaoToRota(key) {
if (!key) return 'aparencia';
return SECAO_ALIASES[key] || key;
}
// secaoToRota removido junto com selecionar (navegacao agora vive
// no MelissaConfigSidebar). SECAO_ALIASES tambem nao e mais usado.
// Componentes externos embedados (todas as páginas de /configuracoes/)
@@ -90,13 +87,13 @@ const COMPONENT_MAP = {
'cfg-creditos-wa': defineAsyncComponent(() => import('@/layout/configuracoes/ConfiguracoesCreditosWhatsappPage.vue')),
'cfg-sms': defineAsyncComponent(() => import('@/layout/configuracoes/ConfiguracoesSmsPage.vue')),
'cfg-email-templates': defineAsyncComponent(() => import('@/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue')),
'cfg-empresa': defineAsyncComponent(() => import('@/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue')),
'cfg-recursos-extras': defineAsyncComponent(() => import('@/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue')),
'cfg-auditoria': defineAsyncComponent(() => import('@/layout/configuracoes/AuditoriaPage.vue')),
// Conta (páginas pessoais que vivem em /account/*)
// 'cfg-perfil' removido virou pagina nativa MelissaPerfil em /melissa/perfil
// 'cfg-plano' removido virou pagina nativa MelissaPlano em /melissa/plano
// 'cfg-negocio' removido virou pagina nativa MelissaNegocio em /melissa/negocio
// 'cfg-negocio' removido virou pagina nativa MelissaNegocio em /melissa/negocio (engloba os campos do antigo cfg-empresa)
// 'cfg-empresa' removido unificado em MelissaNegocio (mesma tabela company_profiles)
// 'cfg-seguranca' removido virou pagina nativa MelissaSeguranca em /melissa/seguranca
};
@@ -214,20 +211,19 @@ const grupos = [
},
{
key: 'plataforma',
label: 'Empresa & Plataforma',
desc: 'Dados da empresa, recursos extras e auditoria.',
icon: 'pi pi-building',
label: 'Plataforma',
desc: 'Recursos extras e auditoria.',
icon: 'pi pi-server',
items: [
{ key: 'cfg-empresa', label: 'Minha Empresa', desc: 'CNPJ, endereço, logomarca e redes sociais.', icon: 'pi pi-building' },
{ key: 'cfg-recursos-extras', label: 'Recursos Extras', desc: 'Amplíe as funcionalidades com recursos adicionais.', icon: 'pi pi-box' },
{ key: 'cfg-auditoria', label: 'Auditoria', desc: 'Registro imutável de operações (LGPD Art. 37).', icon: 'pi pi-shield' }
{ key: 'cfg-recursos-extras', label: 'Recursos Extras', desc: 'Amplíe as funcionalidades com recursos adicionais.', icon: 'pi pi-box' },
{ key: 'cfg-recursos-extras-extrato', label: 'Extrato de Recursos Extras', desc: 'Histórico de débitos e créditos exportável (CSV/PDF).', icon: 'pi pi-list' },
{ key: 'cfg-auditoria', label: 'Auditoria', desc: 'Registro imutável de operações (LGPD Art. 37).', icon: 'pi pi-shield' }
]
}
];
const secoesFlat = computed(() => grupos.flatMap((g) => g.items));
const router = useRouter();
const route = useRoute();
// router/route removidos navegacao migrou pro MelissaConfigSidebar.
const secaoAtiva = ref(rotaToSecao(props.secaoRota));
const secaoAtivaInfo = computed(() => secoesFlat.value.find((s) => s.key === secaoAtiva.value) || secoesFlat.value[0]);
@@ -246,18 +242,8 @@ watch(() => props.secaoRota, (v) => {
if (gk && !openGroups.value.includes(gk)) openGroups.value = [...openGroups.value, gk];
});
function selecionar(item) {
secaoAtiva.value = item.key;
fecharDrawer();
const gk = grupoDaSecao(item.key);
if (gk && !openGroups.value.includes(gk)) openGroups.value = [...openGroups.value, gk];
// Atualiza a URL pra refletir a seção atual sem isso, /melissa/aparencia
// ficava fixo no path enquanto o user navegava em outras seções da config.
const slug = secaoToRota(item.key);
if (route.params?.secao !== slug) {
router.push({ name: 'Melissa', params: { secao: slug } });
}
}
// `selecionar` removido navegacao agora vive no MelissaConfigSidebar
// (compartilhado), que faz router.push direto pelo seu proprio handler.
// Componente embedado da seção ativa (null renderiza inline do Layout Melissa)
const embedComp = computed(() => COMPONENT_MAP[secaoAtiva.value] || null);
@@ -274,6 +260,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre embed (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
@@ -309,7 +300,9 @@ function resetCores() {
v-show="isMobile"
aria-label="Seções de configuração"
>
<div id="mcfg-mobile-drawer-target" class="mcfg-mobile-drawer__scroll" />
<div class="mcfg-mobile-drawer__scroll">
<MelissaConfigList @select="fecharDrawer" />
</div>
</aside>
<Transition name="mcfg-drawer-fade">
<div
@@ -336,6 +329,15 @@ function resetCores() {
<span class="mcfg-page__sub">{{ secaoAtivaInfo?.label }}</span>
</div>
<div class="mcfg-page__actions">
<button
class="mcfg-cfg-btn"
:class="{ 'is-open': cfgOpen }"
v-tooltip.bottom="cfgOpen ? 'Voltar' : 'Outras configurações'"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-list'" />
<span>{{ cfgOpen ? 'Voltar' : 'Outras configs' }}</span>
</button>
<button class="mcfg-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
@@ -343,46 +345,17 @@ function resetCores() {
</header>
<div class="mcfg-body">
<!-- ASIDE Accordion de grupos + cards de seções -->
<Teleport to="#mcfg-mobile-drawer-target" :disabled="!isMobile">
<aside class="mcfg-side">
<Accordion v-model:value="openGroups" multiple class="mcfg-accordion">
<AccordionPanel v-for="g in grupos" :key="g.key" :value="g.key">
<AccordionHeader>
<div class="mcfg-grp-head">
<i :class="g.icon" class="mcfg-grp-icon" />
<div class="mcfg-grp-text">
<span class="mcfg-grp-label">{{ g.label }}</span>
<span class="mcfg-grp-desc">{{ g.desc }}</span>
</div>
<span class="mcfg-grp-badge">{{ g.items.length }}</span>
</div>
</AccordionHeader>
<AccordionContent>
<div class="mcfg-nav-list">
<button
v-for="s in g.items"
:key="s.key"
type="button"
class="mcfg-nav-item"
:class="{ 'is-active': secaoAtiva === s.key }"
@click="selecionar(s)"
>
<i :class="s.icon" class="mcfg-nav-item__icon" />
<div class="mcfg-nav-item__text">
<span class="mcfg-nav-item__label">{{ s.label }}</span>
<span class="mcfg-nav-item__desc">{{ s.desc }}</span>
</div>
</button>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</aside>
</Teleport>
<!-- Lista de configs (alterna com o embed via cfgOpen). -->
<div v-if="cfgOpen" class="mcfg-cfg-list-wrap">
<MelissaConfigList @select="fecharCfg" />
</div>
<!-- O aside interno (Accordion de grupos + items) foi
removido: a navegacao agora vive no MelissaConfigSidebar
global do MelissaLayout (desktop) e no drawer mobile
com MelissaConfigSidebar abaixo. -->
<!-- MAIN Conteúdo da seção ativa -->
<div class="mcfg-main" :class="{ 'is-embed': !isInline }">
<div v-if="!cfgOpen" class="mcfg-main" :class="{ 'is-embed': !isInline }">
<!-- Hero compacto (mostra contexto da seção quando embed; o Layout
Melissa inline mostra título/ações nos próprios cards) -->
<div v-if="!isInline && secaoAtivaInfo" class="mcfg-embed-hero">
@@ -651,6 +624,53 @@ function resetCores() {
}
.mcfg-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* Botao "Outras configs" no header da MelissaConfiguracoes. Click alterna
entre o embed (default) e a lista de configs no body. */
.mcfg-cfg-btn {
height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 11px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mcfg-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mcfg-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mcfg-cfg-btn > i { font-size: 0.85rem; }
.mcfg-cfg-list-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
background: var(--m-bg-soft);
border-radius: 12px;
margin: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcfg-cfg-list-wrap::-webkit-scrollbar { width: 5px; }
.mcfg-cfg-list-wrap::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mcfg-close {
width: 32px; height: 32px;
display: grid; place-items: center;
@@ -957,6 +977,24 @@ function resetCores() {
.mcfg-embed-wrap {
padding: 16px 18px 28px;
}
/* Desktop (>=1024px)
Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha o pattern de MelissaSeguranca/Pagamento:
- 1024px1012px : full-width (right: 6px) overlap minimo
- 1012px2012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%)
Aplicado no .mcfg-page que renderiza TODOS os 17 embeds cfg-*
(precificacao, descontos, excecoes, convenios, wa, wa-templates,
conversas-tags/autoreply/optouts/sla/bots, lembretes, creditos-wa,
sms, email-templates, recursos-extras, auditoria + empresa).
Uma fix pega todos. */
@media (min-width: 1024px) {
.mcfg-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
@media (max-width: 1023px) {
.mcfg-embed-wrap { padding: 12px 12px 24px; }
.mcfg-embed-hero { padding: 10px 12px; }
@@ -1250,25 +1288,19 @@ function resetCores() {
.mcfg-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
padding: 12px 12px 24px;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcfg-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mcfg-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Aside teleportada perde os adornos próprios dentro do drawer */
.mcfg-mobile-drawer__scroll .mcfg-side {
width: 100%;
height: auto;
overflow: visible;
border-right: none;
background: transparent;
padding: 0;
}
/* MelissaConfigList dentro do drawer — herda layout flat e ocupa o scroll. */
.mcfg-mobile-drawer__backdrop {
position: fixed;
inset: 0;
@@ -1290,6 +1322,8 @@ function resetCores() {
.mcfg-page__title > i,
.mcfg-page__title > span:first-of-type { display: none; }
.mcfg-menu-btn--mobile-only { display: inline-flex; }
/* No mobile o drawer ja da acesso via "Seções" — ocultar btn redundante. */
.mcfg-cfg-btn { display: none; }
.mcfg-main__inner { padding: 14px; }
.mcfg-bg-pick { flex-direction: column; }
.mcfg-bg-preview { width: 100%; height: 120px; }
+379
View File
@@ -0,0 +1,379 @@
<script setup>
/*
* MelissaConvenios Pagina nativa Melissa pra "Convenios".
*
* Substitui o embed cfg-convenios. Cadastro de convenios atendidos
* com seus valores. Layout 2-col padrao (chrome igual aos irmaos).
*/
import { ref, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import ConfiguracoesConveniosPage from '@/layout/configuracoes/ConfiguracoesConveniosPage.vue';
const emit = defineEmits(['close']);
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<Transition name="mcv-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mcv-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mcv-mobile-drawer-target" class="mcv-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mcv-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mcv-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mcv-page">
<header class="mcv-page__head">
<button
class="mcv-menu-btn mcv-menu-btn--mobile-only"
v-tooltip.bottom="'Configurações'"
aria-label="Abrir menu de configurações"
@click="toggleDrawer"
>
<i class="pi pi-bars" aria-hidden="true" />
<span>Menu</span>
</button>
<div class="mcv-page__title">
<i class="pi pi-id-card mcv-page__title-icon" aria-hidden="true" />
<span>Convênios</span>
</div>
<div class="mcv-page__actions">
<button
class="mcv-close"
v-tooltip.bottom="'Voltar (Esc)'"
aria-label="Voltar (Esc)"
@click="emit('close')"
>
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
</header>
<div class="mcv-subheader" role="note">
<i class="pi pi-info-circle mcv-subheader__icon" aria-hidden="true" />
<span class="mcv-subheader__text">
Cadastre os convênios que você atende e os valores praticados em cada.
Use ao agendar pacientes que pagam via plano de saúde.
</span>
</div>
<div class="mcv-body">
<Teleport to="#mcv-mobile-drawer-target" :disabled="!isMobile">
<aside class="mcv-side">
<button
class="mcv-cfg-btn"
:class="{ 'is-open': cfgOpen }"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" aria-hidden="true" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mcv-cfg-btn__chev" aria-hidden="true" />
</button>
<div v-if="cfgOpen" class="mcv-side__scroll mcv-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mcv-side__scroll">
<div class="mcv-w mcv-w--side">
<div class="mcv-w__head">
<div class="mcv-w__icon"><i class="pi pi-question-circle" aria-hidden="true" /></div>
<div class="mcv-w__title">
<div class="mcv-w__title-text">Como funciona</div>
<div class="mcv-w__sub">Convênios atendidos</div>
</div>
</div>
<div class="mcv-w__body">
<ul class="mcv-faq">
<li class="mcv-faq__item">
<i class="pi pi-id-card" aria-hidden="true" />
<span><strong>Cadastro:</strong> nome do convênio, valor por sessão e código TUSS (opcional).</span>
</li>
<li class="mcv-faq__item">
<i class="pi pi-user" aria-hidden="true" />
<span><strong>Vincular:</strong> ao cadastrar paciente, escolha o convênio valor entra automático.</span>
</li>
<li class="mcv-faq__item">
<i class="pi pi-eye-slash" aria-hidden="true" />
<span><strong>Inativar:</strong> mantém histórico mas remove dos formulários novos.</span>
</li>
</ul>
</div>
</div>
</div>
</aside>
</Teleport>
<div class="mcv-main">
<ConfiguracoesConveniosPage />
</div>
</div>
</section>
</template>
<style scoped>
.mcv-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex; flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mcv-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mcv-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mcv-page__head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0; gap: 10px;
}
.mcv-page__title {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 10px;
font-size: 1rem; font-weight: 700; color: var(--m-text);
flex-wrap: wrap;
}
.mcv-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mcv-page__actions { display: flex; align-items: center; gap: 6px; }
.mcv-close {
width: 32px; height: 32px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mcv-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mcv-menu-btn {
display: none;
align-items: center; gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem; font-weight: 600;
flex-shrink: 0;
}
.mcv-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mcv-subheader {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mcv-subheader__icon { color: var(--p-primary-color); font-size: 0.85rem; margin-top: 2px; flex-shrink: 0; }
.mcv-body { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.mcv-side {
width: 320px; flex-shrink: 0;
display: flex; flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mcv-side__scroll {
flex: 1; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 12px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcv-side__scroll::-webkit-scrollbar { width: 5px; }
.mcv-side__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mcv-cfg-btn {
display: flex; align-items: center; gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem; font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mcv-cfg-btn:hover { background: var(--m-bg-soft-hover); border-color: var(--m-border-strong); }
.mcv-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mcv-cfg-btn > i:first-child { color: var(--p-primary-color); font-size: 0.92rem; }
.mcv-cfg-btn > span { flex: 1; }
.mcv-cfg-btn__chev { color: var(--m-text-muted); font-size: 0.7rem; }
.mcv-side__scroll--cfg { padding: 8px; gap: 0; }
.mcv-main {
flex: 1; min-width: 0; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 16px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcv-main::-webkit-scrollbar { width: 5px; }
.mcv-main::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
@media (min-width: 1024px) {
.mcv-page { right: max(6px, min(50%, calc(100% - 1006px))); }
.mcv-side > .mcv-side__scroll > .mcv-w--side {
flex-shrink: 0;
height: auto;
display: flex;
flex-direction: column;
}
}
.mcv-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex; flex-direction: column;
}
.mcv-w--side { background: var(--m-bg-medium); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16); }
.mcv-w__head {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mcv-w__icon {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mcv-w__icon > i { font-size: 0.95rem; }
.mcv-w__title { flex: 1; min-width: 0; }
.mcv-w__title-text { font-size: 0.92rem; font-weight: 700; color: var(--m-text); line-height: 1.2; }
.mcv-w__sub { font-size: 0.74rem; color: var(--m-text-muted); margin-top: 2px; line-height: 1.3; }
.mcv-w__body { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.mcv-faq {
display: flex; flex-direction: column; gap: 10px;
list-style: none; margin: 0; padding: 0;
}
.mcv-faq__item {
display: flex; gap: 10px;
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mcv-faq__item > i { color: var(--p-primary-color); font-size: 0.78rem; margin-top: 3px; flex-shrink: 0; }
.mcv-faq__item strong { color: var(--m-text); font-weight: 600; }
.mcv-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh; height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex; flex-direction: column;
}
.mcv-mobile-drawer.is-open { transform: translateX(0); }
.mcv-mobile-drawer__scroll {
flex: 1; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 12px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mcv-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mcv-mobile-drawer__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mcv-mobile-drawer__scroll .mcv-side { width: 100%; border-right: none; }
.mcv-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mcv-drawer-fade-enter-active,
.mcv-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mcv-drawer-fade-enter-from,
.mcv-drawer-fade-leave-to { opacity: 0; }
@media (max-width: 1023px) {
.mcv-body { flex-direction: column; padding: 0; }
.mcv-body > .mcv-side { display: none; }
.mcv-main { width: 100%; padding: 8px; }
.mcv-page__title > span:first-of-type { display: none; }
.mcv-page__title-icon { display: none; }
.mcv-menu-btn--mobile-only { display: inline-flex; }
}
</style>
+17 -1
View File
@@ -47,7 +47,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['visible-change', 'close', 'complete']);
const emit = defineEmits(['visible-change', 'close', 'complete', 'session-end']);
// Estado interno
const exists = ref(false);
@@ -112,8 +112,24 @@ function toggle() {
if (timer) clearInterval(timer);
timer = null;
running.value = false;
// Tempo total cronometrado antes do reset. Pode estourar a duracao
// (seconds < 0) somamos o overshoot. Ex: 50min config, parou em
// 53min => seconds=-180 => elapsed = 3000-(-180) = 3180s.
const elapsedSec = (props.duracaoMinutos * 60) - seconds.value;
// Zera ao parar sessão acabou, deixa pronto pra próxima
seconds.value = props.duracaoMinutos * 60;
// Emite pro pai gravar no DB. Threshold de 5s pra ignorar
// start/stop acidentais. So' faz sentido se ha paciente.
if (pacienteId.value && elapsedSec > 5) {
emit('session-end', {
pacienteId: pacienteId.value,
elapsedSec,
stoppedAt: new Date().toISOString()
});
}
} else {
if (timer) clearInterval(timer);
timer = setInterval(() => {
+427
View File
@@ -0,0 +1,427 @@
<script setup>
/*
* MelissaDescontos Pagina nativa Melissa pra "Descontos por Paciente".
*
* Substitui o embed cfg-descontos. Layout 2-col (chrome igual aos
* irmaos: MelissaPrecificacao/Pagamento/Seguranca/etc):
* - COL 1 (sidebar) Botao Configuracoes + Card "Como funciona"
* - COL 2 (main) Reusa <ConfiguracoesDescontosPage /> direto
*/
import { ref, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import ConfiguracoesDescontosPage from '@/layout/configuracoes/ConfiguracoesDescontosPage.vue';
const emit = defineEmits(['close']);
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<Transition name="mds-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mds-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mds-mobile-drawer-target" class="mds-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mds-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mds-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mds-page">
<header class="mds-page__head">
<button
class="mds-menu-btn mds-menu-btn--mobile-only"
v-tooltip.bottom="'Configurações'"
aria-label="Abrir menu de configurações"
@click="toggleDrawer"
>
<i class="pi pi-bars" aria-hidden="true" />
<span>Menu</span>
</button>
<div class="mds-page__title">
<i class="pi pi-percentage mds-page__title-icon" aria-hidden="true" />
<span>Descontos por Paciente</span>
</div>
<div class="mds-page__actions">
<button
class="mds-close"
v-tooltip.bottom="'Voltar (Esc)'"
aria-label="Voltar (Esc)"
@click="emit('close')"
>
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
</header>
<div class="mds-subheader" role="note">
<i class="pi pi-info-circle mds-subheader__icon" aria-hidden="true" />
<span class="mds-subheader__text">
Cadastre descontos recorrentes (familiar, fidelidade, social, etc).
Aplicados automaticamente ao agendar ou cobrar pacientes vinculados.
</span>
</div>
<div class="mds-body">
<Teleport to="#mds-mobile-drawer-target" :disabled="!isMobile">
<aside class="mds-side">
<button
class="mds-cfg-btn"
:class="{ 'is-open': cfgOpen }"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" aria-hidden="true" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mds-cfg-btn__chev" aria-hidden="true" />
</button>
<div v-if="cfgOpen" class="mds-side__scroll mds-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mds-side__scroll">
<div class="mds-w mds-w--side">
<div class="mds-w__head">
<div class="mds-w__icon"><i class="pi pi-question-circle" aria-hidden="true" /></div>
<div class="mds-w__title">
<div class="mds-w__title-text">Como funciona</div>
<div class="mds-w__sub">Descontos recorrentes</div>
</div>
</div>
<div class="mds-w__body">
<ul class="mds-faq">
<li class="mds-faq__item">
<i class="pi pi-percentage" aria-hidden="true" />
<span><strong>Tipo:</strong> percentual (%) ou valor fixo (R$).</span>
</li>
<li class="mds-faq__item">
<i class="pi pi-user" aria-hidden="true" />
<span><strong>Aplicação:</strong> vincule ao paciente desconto entra automático.</span>
</li>
<li class="mds-faq__item">
<i class="pi pi-calendar" aria-hidden="true" />
<span><strong>Validade:</strong> opcional. Sem data, vale até remover.</span>
</li>
</ul>
</div>
</div>
</div>
</aside>
</Teleport>
<div class="mds-main">
<ConfiguracoesDescontosPage />
</div>
</div>
</section>
</template>
<style scoped>
.mds-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mds-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mds-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mds-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mds-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 700;
color: var(--m-text);
flex-wrap: wrap;
}
.mds-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mds-page__actions { display: flex; align-items: center; gap: 6px; }
.mds-close {
width: 32px; height: 32px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mds-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mds-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mds-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mds-subheader {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mds-subheader__icon { color: var(--p-primary-color); font-size: 0.85rem; margin-top: 2px; flex-shrink: 0; }
.mds-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.mds-side {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mds-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mds-side__scroll::-webkit-scrollbar { width: 5px; }
.mds-side__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mds-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mds-cfg-btn:hover { background: var(--m-bg-soft-hover); border-color: var(--m-border-strong); }
.mds-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mds-cfg-btn > i:first-child { color: var(--p-primary-color); font-size: 0.92rem; }
.mds-cfg-btn > span { flex: 1; }
.mds-cfg-btn__chev { color: var(--m-text-muted); font-size: 0.7rem; }
.mds-side__scroll--cfg { padding: 8px; gap: 0; }
.mds-main {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mds-main::-webkit-scrollbar { width: 5px; }
.mds-main::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
@media (min-width: 1024px) {
.mds-page { right: max(6px, min(50%, calc(100% - 1006px))); }
.mds-side > .mds-side__scroll > .mds-w--side {
flex-shrink: 0;
height: auto;
display: flex;
flex-direction: column;
}
}
.mds-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
}
.mds-w--side { background: var(--m-bg-medium); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16); }
.mds-w__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mds-w__icon {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mds-w__icon > i { font-size: 0.95rem; }
.mds-w__title { flex: 1; min-width: 0; }
.mds-w__title-text { font-size: 0.92rem; font-weight: 700; color: var(--m-text); line-height: 1.2; }
.mds-w__sub { font-size: 0.74rem; color: var(--m-text-muted); margin-top: 2px; line-height: 1.3; }
.mds-w__body { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.mds-faq {
display: flex; flex-direction: column; gap: 10px;
list-style: none; margin: 0; padding: 0;
}
.mds-faq__item {
display: flex; gap: 10px;
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mds-faq__item > i {
color: var(--p-primary-color);
font-size: 0.78rem;
margin-top: 3px;
flex-shrink: 0;
}
.mds-faq__item strong { color: var(--m-text); font-weight: 600; }
.mds-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.mds-mobile-drawer.is-open { transform: translateX(0); }
.mds-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mds-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mds-mobile-drawer__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mds-mobile-drawer__scroll .mds-side { width: 100%; border-right: none; }
.mds-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mds-drawer-fade-enter-active,
.mds-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mds-drawer-fade-enter-from,
.mds-drawer-fade-leave-to { opacity: 0; }
@media (max-width: 1023px) {
.mds-body { flex-direction: column; padding: 0; }
.mds-body > .mds-side { display: none; }
.mds-main { width: 100%; padding: 8px; }
.mds-page__title > span:first-of-type { display: none; }
.mds-page__title-icon { display: none; }
.mds-menu-btn--mobile-only { display: inline-flex; }
}
</style>
+386
View File
@@ -0,0 +1,386 @@
<script setup>
/*
* MelissaExcecoes Pagina nativa Melissa pra "Excecoes Financeiras".
*
* Substitui o embed cfg-excecoes. Define o que cobrar em situacoes
* excepcionais (faltas, cancelamentos com pouca antecedencia, etc).
* Layout 2-col padrao (chrome igual aos irmaos).
*/
import { ref, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import ConfiguracoesExcecoesFinanceirasPage from '@/layout/configuracoes/ConfiguracoesExcecoesFinanceirasPage.vue';
const emit = defineEmits(['close']);
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<Transition name="mxc-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mxc-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mxc-mobile-drawer-target" class="mxc-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mxc-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mxc-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mxc-page">
<header class="mxc-page__head">
<button
class="mxc-menu-btn mxc-menu-btn--mobile-only"
v-tooltip.bottom="'Configurações'"
aria-label="Abrir menu de configurações"
@click="toggleDrawer"
>
<i class="pi pi-bars" aria-hidden="true" />
<span>Menu</span>
</button>
<div class="mxc-page__title">
<i class="pi pi-exclamation-triangle mxc-page__title-icon" aria-hidden="true" />
<span>Exceções Financeiras</span>
</div>
<div class="mxc-page__actions">
<button
class="mxc-close"
v-tooltip.bottom="'Voltar (Esc)'"
aria-label="Voltar (Esc)"
@click="emit('close')"
>
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
</header>
<div class="mxc-subheader" role="note">
<i class="pi pi-info-circle mxc-subheader__icon" aria-hidden="true" />
<span class="mxc-subheader__text">
Defina o que cobrar em faltas sem aviso, cancelamentos com pouca
antecedência e outras situações excepcionais.
</span>
</div>
<div class="mxc-body">
<Teleport to="#mxc-mobile-drawer-target" :disabled="!isMobile">
<aside class="mxc-side">
<button
class="mxc-cfg-btn"
:class="{ 'is-open': cfgOpen }"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" aria-hidden="true" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mxc-cfg-btn__chev" aria-hidden="true" />
</button>
<div v-if="cfgOpen" class="mxc-side__scroll mxc-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mxc-side__scroll">
<div class="mxc-w mxc-w--side">
<div class="mxc-w__head">
<div class="mxc-w__icon"><i class="pi pi-question-circle" aria-hidden="true" /></div>
<div class="mxc-w__title">
<div class="mxc-w__title-text">Como funciona</div>
<div class="mxc-w__sub">Regras de exceção</div>
</div>
</div>
<div class="mxc-w__body">
<ul class="mxc-faq">
<li class="mxc-faq__item">
<i class="pi pi-times-circle" aria-hidden="true" />
<span><strong>Falta sem aviso:</strong> cobrar 100%, 50% ou nada da sessão.</span>
</li>
<li class="mxc-faq__item">
<i class="pi pi-clock" aria-hidden="true" />
<span><strong>Cancelamento tardio:</strong> janela em horas e percentual cobrado.</span>
</li>
<li class="mxc-faq__item">
<i class="pi pi-bolt" aria-hidden="true" />
<span><strong>Aplicação:</strong> automática quando o motivo é registrado na sessão.</span>
</li>
</ul>
</div>
</div>
</div>
</aside>
</Teleport>
<div class="mxc-main">
<ConfiguracoesExcecoesFinanceirasPage />
</div>
</div>
</section>
</template>
<style scoped>
.mxc-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mxc-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mxc-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mxc-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mxc-page__title {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 10px;
font-size: 1rem; font-weight: 700; color: var(--m-text);
flex-wrap: wrap;
}
.mxc-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mxc-page__actions { display: flex; align-items: center; gap: 6px; }
.mxc-close {
width: 32px; height: 32px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mxc-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mxc-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mxc-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mxc-subheader {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mxc-subheader__icon { color: var(--p-primary-color); font-size: 0.85rem; margin-top: 2px; flex-shrink: 0; }
.mxc-body { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.mxc-side {
width: 320px; flex-shrink: 0;
display: flex; flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mxc-side__scroll {
flex: 1; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 12px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mxc-side__scroll::-webkit-scrollbar { width: 5px; }
.mxc-side__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mxc-cfg-btn {
display: flex; align-items: center; gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem; font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mxc-cfg-btn:hover { background: var(--m-bg-soft-hover); border-color: var(--m-border-strong); }
.mxc-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mxc-cfg-btn > i:first-child { color: var(--p-primary-color); font-size: 0.92rem; }
.mxc-cfg-btn > span { flex: 1; }
.mxc-cfg-btn__chev { color: var(--m-text-muted); font-size: 0.7rem; }
.mxc-side__scroll--cfg { padding: 8px; gap: 0; }
.mxc-main {
flex: 1; min-width: 0; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 16px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mxc-main::-webkit-scrollbar { width: 5px; }
.mxc-main::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
@media (min-width: 1024px) {
.mxc-page { right: max(6px, min(50%, calc(100% - 1006px))); }
.mxc-side > .mxc-side__scroll > .mxc-w--side {
flex-shrink: 0;
height: auto;
display: flex;
flex-direction: column;
}
}
.mxc-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex; flex-direction: column;
}
.mxc-w--side { background: var(--m-bg-medium); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16); }
.mxc-w__head {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mxc-w__icon {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mxc-w__icon > i { font-size: 0.95rem; }
.mxc-w__title { flex: 1; min-width: 0; }
.mxc-w__title-text { font-size: 0.92rem; font-weight: 700; color: var(--m-text); line-height: 1.2; }
.mxc-w__sub { font-size: 0.74rem; color: var(--m-text-muted); margin-top: 2px; line-height: 1.3; }
.mxc-w__body { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.mxc-faq {
display: flex; flex-direction: column; gap: 10px;
list-style: none; margin: 0; padding: 0;
}
.mxc-faq__item {
display: flex; gap: 10px;
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mxc-faq__item > i { color: var(--p-primary-color); font-size: 0.78rem; margin-top: 3px; flex-shrink: 0; }
.mxc-faq__item strong { color: var(--m-text); font-weight: 600; }
.mxc-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh; height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex; flex-direction: column;
}
.mxc-mobile-drawer.is-open { transform: translateX(0); }
.mxc-mobile-drawer__scroll {
flex: 1; min-height: 0;
overflow-y: auto; overflow-x: hidden;
padding: 12px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mxc-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mxc-mobile-drawer__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mxc-mobile-drawer__scroll .mxc-side { width: 100%; border-right: none; }
.mxc-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mxc-drawer-fade-enter-active,
.mxc-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mxc-drawer-fade-enter-from,
.mxc-drawer-fade-leave-to { opacity: 0; }
@media (max-width: 1023px) {
.mxc-body { flex-direction: column; padding: 0; }
.mxc-body > .mxc-side { display: none; }
.mxc-main { width: 100%; padding: 8px; }
.mxc-page__title > span:first-of-type { display: none; }
.mxc-page__title-icon { display: none; }
.mxc-menu-btn--mobile-only { display: inline-flex; }
}
</style>
+52 -22
View File
@@ -28,16 +28,20 @@ defineProps({
saudacao: { type: String, required: true },
usuario: { type: String, default: 'Dr. Leonardo' },
resumoPartes: { type: Array, default: () => [] },
filtroTipo: { type: String, default: null }
filtroTipo: { type: String, default: null },
// Quando true, cada bloco de texto (relogio, data, saudacao, resumo)
// ganha um fundo solido translucido + borda + padding. Util pra quem
// usa wallpapers com pouca transparencia onde o text-shadow nao basta.
textBg: { type: Boolean, default: false }
});
const emit = defineEmits(['cronometro', 'toggle-filtro']);
</script>
<template>
<header class="text-center text-white drop-shadow-lg select-none">
<header class="text-center text-white drop-shadow-lg select-none" :class="{ 'has-text-bg': textBg }">
<div class="inline-flex items-center gap-6">
<div class="clock-display">{{ hora }}</div>
<div class="clock-display"><span class="hero-text">{{ hora }}</span></div>
<button
class="crono-icon-btn w-12 h-12 rounded-xl grid place-items-center cursor-pointer shrink-0"
title="Cronômetro de sessão"
@@ -47,29 +51,31 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
</button>
</div>
<div class="text-lg md:text-xl font-light tracking-wide text-white/90 mt-1 capitalize">
{{ dataExtenso }}
<div class="hero-line text-lg md:text-xl font-light tracking-wide text-white/90 mt-1 capitalize">
<span class="hero-text">{{ dataExtenso }}</span>
</div>
<div class="text-2xl md:text-3xl font-light mt-6 text-white/95 tracking-tight">
{{ saudacao }}, <span class="font-normal">{{ usuario }}</span>.
<div class="hero-line text-2xl md:text-3xl font-light mt-6 text-white/95 tracking-tight">
<span class="hero-text">{{ saudacao }}, <span class="font-normal">{{ usuario }}</span>.</span>
</div>
<div class="text-base md:text-lg font-light text-white/70 mt-2 tracking-wide">
<template v-if="resumoPartes.length === 0">
Sua agenda está livre hoje.
</template>
<template v-else>
Hoje
<template v-for="(p, i) in resumoPartes" :key="p.tipo">
<button
type="button"
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="emit('toggle-filtro', p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
<div class="hero-line text-base md:text-lg font-light text-white/70 mt-2 tracking-wide">
<span class="hero-text">
<template v-if="resumoPartes.length === 0">
Sua agenda está livre hoje.
</template>
<template v-else>
Hoje
<template v-for="(p, i) in resumoPartes" :key="p.tipo">
<button
type="button"
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="emit('toggle-filtro', p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
</span>
</div>
</header>
</template>
@@ -128,4 +134,28 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
font-weight: 500;
border-bottom: 1px solid var(--m-text);
}
/* Modo "fundo nos textos soltos"
Toggle no painel Personalizar. Cada bloco de texto (.hero-text)
ganha um fundo solido translucido + borda + padding pra ficar
legivel sobre wallpapers com pouca transparencia. Cor flipa
com light/dark via --m-hero-text-bg / --m-hero-text-border. */
.has-text-bg .hero-text {
display: inline-block;
background: var(--m-hero-text-bg, rgba(0, 0, 0, 0.6));
border: 1px solid var(--m-hero-text-border, rgba(255, 255, 255, 0.12));
border-radius: 12px;
padding: 4px 14px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* Relogio: padding maior porque o texto e' gigante */
.has-text-bg .clock-display .hero-text {
padding: 6px 22px;
border-radius: 18px;
}
/* Linhas inferiores (data, saudacao, resumo) ganham margem extra
pra o fundo nao colar visualmente no de cima. */
.has-text-bg .hero-line { margin-top: 12px; }
.has-text-bg .hero-line:first-of-type { margin-top: 8px; }
</style>
+328 -6
View File
@@ -20,6 +20,7 @@ import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useLayout } from '@/layout/composables/layout';
import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options';
import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes';
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import MelissaCronometro from './MelissaCronometro.vue';
import MelissaCard from './MelissaCard.vue';
@@ -46,6 +47,26 @@ import MelissaBloqueios from './MelissaBloqueios.vue';
import MelissaAgendador from './MelissaAgendador.vue';
import MelissaAgendaConfig from './MelissaAgendaConfig.vue';
import MelissaPagamento from './MelissaPagamento.vue';
import MelissaPrecificacao from './MelissaPrecificacao.vue';
import MelissaDescontos from './MelissaDescontos.vue';
import MelissaExcecoes from './MelissaExcecoes.vue';
import MelissaConvenios from './MelissaConvenios.vue';
import MelissaCfgWa from './MelissaCfgWa.vue';
import MelissaCfgWaPessoal from './MelissaCfgWaPessoal.vue';
import MelissaCfgWaOficial from './MelissaCfgWaOficial.vue';
import MelissaCfgWaTemplates from './MelissaCfgWaTemplates.vue';
import MelissaCfgConversasTags from './MelissaCfgConversasTags.vue';
import MelissaCfgConversasAutoreply from './MelissaCfgConversasAutoreply.vue';
import MelissaCfgConversasOptouts from './MelissaCfgConversasOptouts.vue';
import MelissaCfgConversasSla from './MelissaCfgConversasSla.vue';
import MelissaCfgConversasBots from './MelissaCfgConversasBots.vue';
import MelissaCfgLembretes from './MelissaCfgLembretes.vue';
import MelissaCfgCreditosWa from './MelissaCfgCreditosWa.vue';
import MelissaCfgSms from './MelissaCfgSms.vue';
import MelissaCfgEmailTemplates from './MelissaCfgEmailTemplates.vue';
import MelissaCfgRecursosExtras from './MelissaCfgRecursosExtras.vue';
import MelissaCfgRecursosExtrasExtrato from './MelissaCfgRecursosExtrasExtrato.vue';
import MelissaCfgAuditoria from './MelissaCfgAuditoria.vue';
// Sidebar global de configs removido substituido por botao + popover
// (MelissaConfigPopover) dentro de cada pagina de config. Resolveu lag
// de scroll que o overlay sempre visivel causava em mobile.
@@ -68,6 +89,7 @@ import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaDockPins } from './composables/useMelissaDockPins';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
@@ -193,6 +215,27 @@ const SECOES = {
'agenda-config': { label: 'Configurações da Agenda', icon: 'pi pi-calendar', descricao: 'Jornada (dias e horários), ritmo das sessões e agendamento online.' },
// Pagina nativa de formas de pagamento (MelissaPagamento) saiu do MelissaConfiguracoes
pagamento: { label: 'Formas de Pagamento', icon: 'pi pi-wallet', descricao: 'Pix, depósito, dinheiro, cartão e convênio.' },
// Paginas nativas de configuracoes financeiras saidas do MelissaConfiguracoes
'cfg-precificacao': { label: 'Precificação', icon: 'pi pi-tag', descricao: 'Valor padrão da sessão e preços por tipo de compromisso.' },
'cfg-descontos': { label: 'Descontos por Paciente', icon: 'pi pi-percentage', descricao: 'Descontos recorrentes aplicados automaticamente.' },
'cfg-excecoes': { label: 'Exceções Financeiras', icon: 'pi pi-exclamation-triangle', descricao: 'O que cobrar em faltas, cancelamentos e situações excepcionais.' },
'cfg-convenios': { label: 'Convênios', icon: 'pi pi-id-card', descricao: 'Cadastre os convênios que você atende e seus valores.' },
'cfg-wa': { label: 'Canal WhatsApp', icon: 'pi pi-whatsapp', descricao: 'Escolha o canal de envio: oficial AgenciaPSI ou pessoal.' },
'cfg-wa-pessoal': { label: 'WhatsApp Pessoal', icon: 'pi pi-mobile', descricao: 'Conecte seu próprio número via QR code.' },
'cfg-wa-oficial': { label: 'WhatsApp Oficial', icon: 'pi pi-verified', descricao: 'Número provisionado pela AgenciaPSI via API oficial Meta.' },
'cfg-wa-templates': { label: 'Templates WhatsApp', icon: 'pi pi-file-edit', descricao: 'Personalize os textos enviados ou volte ao padrão.' },
'cfg-conversas-tags': { label: 'Tags de Conversa', icon: 'pi pi-tag', descricao: 'Etiquetas custom pra classificar threads no CRM.' },
'cfg-conversas-autoreply': { label: 'Auto-reply WhatsApp', icon: 'pi pi-reply', descricao: 'Resposta automática quando paciente escreve fora do horário.' },
'cfg-conversas-optouts': { label: 'Opt-outs (LGPD)', icon: 'pi pi-ban', descricao: 'Números que pediram pra não receber mensagens. LGPD Art. 18.' },
'cfg-conversas-sla': { label: 'SLA de resposta', icon: 'pi pi-stopwatch', descricao: 'Tempo máximo pra responder. Alerta quando estourar.' },
'cfg-conversas-bots': { label: 'Bot de triagem', icon: 'pi pi-android', descricao: 'Coleta nome e motivo via WhatsApp antes do humano.' },
'cfg-lembretes': { label: 'Lembretes de Sessão', icon: 'pi pi-bell', descricao: 'WhatsApp automático antes das sessões agendadas.' },
'cfg-creditos-wa': { label: 'Créditos WhatsApp', icon: 'pi pi-credit-card', descricao: 'Compre pacotes de mensagens, veja saldo e extrato.' },
'cfg-sms': { label: 'SMS', icon: 'pi pi-comment', descricao: 'Backup quando WhatsApp falha. Gerencie créditos SMS.' },
'cfg-email-templates': { label: 'Templates de E-mail', icon: 'pi pi-envelope', descricao: 'Personalize os e-mails enviados aos pacientes.' },
'cfg-recursos-extras': { label: 'Recursos Extras', icon: 'pi pi-box', descricao: 'Amplie as funcionalidades com recursos adicionais.' },
'cfg-recursos-extras-extrato': { label: 'Extrato de Recursos Extras', icon: 'pi pi-list', descricao: 'Histórico de débitos e créditos exportável.' },
'cfg-auditoria': { label: 'Auditoria', icon: 'pi pi-shield', descricao: 'Registro imutável de operações (LGPD Art. 37).' },
// Pagina nativa de alterar plano (MelissaAlterarPlano) substitui /therapist/upgrade
'alterar-plano': { label: 'Alterar Plano', icon: 'pi pi-arrow-up-right', descricao: 'Escolha um plano pessoal pra ativar todos os recursos.' },
// Onda 1 pages embedadas via MelissaEmbed (1-coluna, hero glass)
@@ -221,6 +264,12 @@ const MELISSA_NON_CONFIG_SLUGS = new Set([
'documentos', 'documentos-templates', 'relatorios',
'perfil', 'plano', 'negocio', 'seguranca', 'bloqueios', 'alterar-plano',
'online-scheduling', 'agenda-config', 'pagamento',
'cfg-precificacao', 'cfg-descontos', 'cfg-excecoes', 'cfg-convenios',
'cfg-wa', 'cfg-wa-pessoal', 'cfg-wa-oficial', 'cfg-wa-templates',
'cfg-conversas-tags', 'cfg-conversas-autoreply', 'cfg-conversas-optouts',
'cfg-conversas-sla', 'cfg-conversas-bots',
'cfg-lembretes', 'cfg-creditos-wa', 'cfg-sms',
'cfg-email-templates', 'cfg-recursos-extras', 'cfg-recursos-extras-extrato', 'cfg-auditoria',
...MELISSA_EMBED_KEYS
]);
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
@@ -283,6 +332,14 @@ function fecharSecao() {
router.push({ name: 'Melissa', params: {} });
}
// Click "Cores do Tema" no menu principal: fecha qualquer fake dialog
// aberto (perfil/plano/negocio/seguranca/pagamento/agendador/cfg-*) e
// abre o painel Personalizar (cog top-right).
function onMenuOpenSettings() {
fecharSecao();
settingsOpen.value = true;
}
// Pins dinâmicos do dock (híbrido: 4 fixos + 3 MRU)
const dockPins = useMelissaDockPins();
const pinContextMenu = ref(null);
@@ -423,6 +480,21 @@ const {
clearBg
} = useMelissaWallpaper();
// Nome do tema selecionado em "Personalizar > Temas" (Freud/Klein/Jung).
// E o ID persistido wallpaper e' resolvido a partir dele no boot
// (sem guardar a data URL gigante no DB). null = wallpaper custom ou padrao.
const themeName = ref(null);
function setThemeName(name) {
if (name && !MELISSA_THEME_NAMES.has(name)) name = null;
themeName.value = name || null;
}
// Quando ON, os textos do hero (relogio, saudacao, resumo) ganham um
// fundo solido translucido + borda + padding. Util pra wallpapers
// com pouca transparencia onde o text-shadow nao da legibilidade.
const textBgEnabled = ref(false);
function setTextBgEnabled(v) { textBgEnabled.value = !!v; }
//
// Tema (dark/light + cor primária) usa a infra existente do app
//
@@ -531,6 +603,7 @@ const eventoSelecionado = ref(null);
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast();
const tenantStore = useTenantStore();
const conversationDrawerStore = useConversationDrawerStore();
//
@@ -881,6 +954,73 @@ function fecharCronometro() {
cronoRef.value?.fechar();
}
// Cronometro: salvar tempo na sessao agendada
// Quando o user para o cronometro com paciente selecionado, busca a
// sessao agendada do dia desse paciente em agenda_eventos e grava o
// tempo cronometrado em extra_fields.cronometro_*. Se nao encontrar
// sessao agendada hoje, avisa e nao falha silencioso.
async function onCronometroSessionEnd({ pacienteId, elapsedSec, stoppedAt }) {
if (!pacienteId || !Number.isFinite(elapsedSec) || elapsedSec <= 0) return;
const tenantId = tenantStore?.activeTenantId || tenantStore?.tenantId;
if (!tenantId) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Não foi possível salvar o tempo.', life: 3500 });
return;
}
// Janela do dia em ISO (00:00 ate 23:59:59 local).
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0).toISOString();
const tomorrowStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0).toISOString();
try {
const { data: sessao, error } = await supabase
.from('agenda_eventos')
.select('id, extra_fields, inicio_em')
.eq('tenant_id', tenantId)
.eq('patient_id', pacienteId)
.eq('tipo', 'sessao')
.gte('inicio_em', todayStart)
.lt('inicio_em', tomorrowStart)
.order('inicio_em', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
if (!sessao) {
toast.add({
severity: 'warn',
summary: 'Sessão não encontrada',
detail: 'Não há sessão agendada hoje pra este paciente — tempo não foi salvo.',
life: 4500
});
return;
}
const newExtra = {
...(sessao.extra_fields && typeof sessao.extra_fields === 'object' ? sessao.extra_fields : {}),
cronometro_duracao_seg: Math.round(elapsedSec),
cronometro_parado_em: stoppedAt
};
const { error: upErr } = await supabase
.from('agenda_eventos')
.update({ extra_fields: newExtra })
.eq('id', sessao.id);
if (upErr) throw upErr;
const min = Math.round(elapsedSec / 60);
toast.add({
severity: 'success',
summary: 'Tempo registrado',
detail: `${min} min cronometrados salvos na sessão.`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao salvar tempo',
detail: e?.message || 'Tente novamente.',
life: 4500
});
}
}
// Provide das prefs/refs pro MelissaConfiguracoes (página interna de
// configs). Posicionado aqui pra que TODAS as refs/funções referenciadas
// já estejam definidas no momento do setup. A página lê/escreve direto
@@ -907,7 +1047,13 @@ provide('melissaSettings', {
use24h,
// cronômetro
toqueTermino,
testarToque
testarToque,
// tema (bundle wallpaper + cores) Freud/Klein/Jung
themeName,
setThemeName,
// fundo nos textos do hero (relogio, saudacao, resumo)
textBgEnabled,
setTextBgEnabled
});
//
@@ -948,6 +1094,14 @@ function applyPrefsPayload(prefs) {
if (prefs.cardsLayout === 'linha-unica' || prefs.cardsLayout === 'duas-linhas') {
cardsLayout.value = prefs.cardsLayout;
}
if (typeof prefs.themeName === 'string' && MELISSA_THEME_NAMES.has(prefs.themeName)) {
themeName.value = prefs.themeName;
} else if (prefs.themeName === null) {
themeName.value = null;
}
if (typeof prefs.textBgEnabled === 'boolean') {
textBgEnabled.value = prefs.textBgEnabled;
}
}
function currentPrefsSnapshot() {
@@ -957,7 +1111,9 @@ function currentPrefsSnapshot() {
bgImageOpacity: bgImageOpacity.value,
use24h: use24h.value,
cardsAtivos: cardsAtivos.value,
cardsLayout: cardsLayout.value
cardsLayout: cardsLayout.value,
themeName: themeName.value,
textBgEnabled: textBgEnabled.value
};
}
@@ -1036,6 +1192,29 @@ async function saveDbPrefs() {
}
}
// Quando themeName carregou mas bgUrl ainda esta vazio (ex: 1a vez em outro
// device), resolve a imagem do tema e gera a data URL. Idempotente: se ja
// tem bgUrl, nao mexe (custom upload ou data URL ja restaurada do storage).
async function resolveThemeWallpaperIfNeeded() {
if (!themeName.value) return;
if (bgUrl.value) return;
const t = findMelissaTheme(themeName.value);
if (!t) return;
try {
const res = await fetch(t.image);
const blob = await res.blob();
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = reject;
r.readAsDataURL(blob);
});
bgUrl.value = dataUrl;
} catch {
bgUrl.value = t.image; // fallback: URL direta funciona na sessao
}
}
function queueDbSave() {
if (dbSaveTimer) clearTimeout(dbSaveTimer);
dbSaveTimer = setTimeout(saveDbPrefs, 600);
@@ -1043,7 +1222,7 @@ function queueDbSave() {
// Salva em qualquer mudança das prefs (deep no array de cardsAtivos pra pegar splice/push)
watch(
[toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout],
[toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout, themeName, textBgEnabled],
() => {
saveLayoutPrefs();
queueDbSave();
@@ -1055,6 +1234,9 @@ watch(
onMounted(async () => {
loadLocalPrefs(); // sync: paint imediato com valores cached
await loadDbPrefs(); // async: sobrescreve com valores autoritativos do DB
// Se o user logou em outro device, themeName vem do DB mas bgUrl
// (data URL) nao resolvemos a imagem do tema agora.
await resolveThemeWallpaperIfNeeded();
});
// Auto-scroll inicial + ResizeObserver da timeline migrou pro
@@ -1128,6 +1310,12 @@ function onKeydown(e) {
<!-- PLANO DE TRÁS Resumo (recebe blur quando workspace abre) -->
<!-- -->
<div class="win11-summary" :class="{ 'is-behind': summaryDimmed }">
<!-- Faixa de fundo do topbar gradiente horizontal
(cor solida na direita -> transparente na esquerda)
pra dar legibilidade aos icones sem virar barra solida.
Cor flipa com light/dark via --m-band. -->
<div class="melissa-topbar-band" aria-hidden="true"></div>
<!-- Topbar Melissa (canto sup. direito): plan-DEV + notificações
+ ajuda + cog. Os 3 primeiros vêm do AppTopbar replicados
aqui porque a rota /melissa é fullscreen e não monta o
@@ -1196,6 +1384,7 @@ function onKeydown(e) {
:saudacao="saudacao"
:resumo-partes="resumoPartes"
:filtro-tipo="filtroTipo"
:text-bg="textBgEnabled"
@cronometro="abrirCronometro"
@toggle-filtro="toggleFiltro"
/>
@@ -1470,6 +1659,7 @@ function onKeydown(e) {
:secao-ativa="secaoAberta"
@select="abrirSecao"
@close="closeWorkspace"
@open-settings="onMenuOpenSettings"
/>
</Transition>
@@ -1710,6 +1900,91 @@ function onKeydown(e) {
@close="fecharSecao"
/>
<MelissaPrecificacao
v-if="layoutReady && secaoAberta === 'cfg-precificacao'"
@close="fecharSecao"
/>
<MelissaDescontos
v-if="layoutReady && secaoAberta === 'cfg-descontos'"
@close="fecharSecao"
/>
<MelissaExcecoes
v-if="layoutReady && secaoAberta === 'cfg-excecoes'"
@close="fecharSecao"
/>
<MelissaConvenios
v-if="layoutReady && secaoAberta === 'cfg-convenios'"
@close="fecharSecao"
/>
<MelissaCfgWa
v-if="layoutReady && secaoAberta === 'cfg-wa'"
@close="fecharSecao"
/>
<MelissaCfgWaPessoal
v-if="layoutReady && secaoAberta === 'cfg-wa-pessoal'"
@close="fecharSecao"
/>
<MelissaCfgWaOficial
v-if="layoutReady && secaoAberta === 'cfg-wa-oficial'"
@close="fecharSecao"
/>
<MelissaCfgWaTemplates
v-if="layoutReady && secaoAberta === 'cfg-wa-templates'"
@close="fecharSecao"
/>
<MelissaCfgConversasTags
v-if="layoutReady && secaoAberta === 'cfg-conversas-tags'"
@close="fecharSecao"
/>
<MelissaCfgConversasAutoreply
v-if="layoutReady && secaoAberta === 'cfg-conversas-autoreply'"
@close="fecharSecao"
/>
<MelissaCfgConversasOptouts
v-if="layoutReady && secaoAberta === 'cfg-conversas-optouts'"
@close="fecharSecao"
/>
<MelissaCfgConversasSla
v-if="layoutReady && secaoAberta === 'cfg-conversas-sla'"
@close="fecharSecao"
/>
<MelissaCfgConversasBots
v-if="layoutReady && secaoAberta === 'cfg-conversas-bots'"
@close="fecharSecao"
/>
<MelissaCfgLembretes
v-if="layoutReady && secaoAberta === 'cfg-lembretes'"
@close="fecharSecao"
/>
<MelissaCfgCreditosWa
v-if="layoutReady && secaoAberta === 'cfg-creditos-wa'"
@close="fecharSecao"
/>
<MelissaCfgSms
v-if="layoutReady && secaoAberta === 'cfg-sms'"
@close="fecharSecao"
/>
<MelissaCfgEmailTemplates
v-if="layoutReady && secaoAberta === 'cfg-email-templates'"
@close="fecharSecao"
/>
<MelissaCfgRecursosExtras
v-if="layoutReady && secaoAberta === 'cfg-recursos-extras'"
@close="fecharSecao"
/>
<MelissaCfgRecursosExtrasExtrato
v-if="layoutReady && secaoAberta === 'cfg-recursos-extras-extrato'"
@close="fecharSecao"
/>
<MelissaCfgAuditoria
v-if="layoutReady && secaoAberta === 'cfg-auditoria'"
@close="fecharSecao"
/>
<MelissaConfiguracoes
v-if="layoutReady && isMelissaConfigRoute(secaoAberta)"
:secao-rota="secaoAberta"
@@ -1780,6 +2055,7 @@ function onKeydown(e) {
:duracao-minutos="CONFIG_DURACAO_MIN"
:toque-termino="toqueTermino"
@visible-change="cronoVisible = $event"
@session-end="onCronometroSessionEnd"
/>
<!-- Drawer de conversas (WhatsApp): mesmo padrão do AppLayout.
@@ -2428,12 +2704,45 @@ function onKeydown(e) {
cronômetro, futuros). Páginas fullscreen reservam esse espaço
no inset bottom pra não ficar atrás do dock. */
--m-dock-h: 76px;
/* Faixa de fundo (top + dock)
Cor solida com 80% de opacidade pra fazer gradiente horizontal
e dar legibilidade aos icones sem virar barra hard. Default =
dark (preto); light flipa pra branco (override em html:not(.app-dark)). */
--m-band: rgba(0, 0, 0, 0.8);
/* Fundo dos textos do hero (relogio/saudacao/resumo)
Toggle "fundo nos textos soltos" no painel Personalizar. 60% de
opacidade pra dar legibilidade sobre wallpapers complexos sem
sumir totalmente o look do bg. Default = dark; light flipa. */
--m-hero-text-bg: rgba(0, 0, 0, 0.6);
--m-hero-text-border: rgba(255, 255, 255, 0.12);
}
/* Faixa de fundo do topbar (canto sup. direito)
Gradiente horizontal: cor solida na direita (onde os icones vivem)
e fade pra transparente na esquerda. z-index abaixo do topbar
(z-30) e acima do conteudo principal. */
.melissa-topbar-band {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80px;
z-index: 25;
pointer-events: none;
background: linear-gradient(
to left,
var(--m-band) 0%,
var(--m-band) 25%,
transparent 75%
);
}
/* Dock (global pra atravessar Teleport + evitar perda de scoped
em static-hoisted nodes). Faixa horizontal no bottom. Sem bg.
Items individuais (chip cronômetro, dock-actions, futuros) têm
visuais próprios e posicionam-se dentro do flexbox. */
em static-hoisted nodes). Faixa horizontal no bottom com gradiente
espelhado: cor solida na esquerda (onde os pins ficam) e fade
pra transparente na direita. */
.melissa-dock {
position: fixed;
bottom: 0;
@@ -2447,6 +2756,12 @@ function onKeydown(e) {
padding: 0 1.5rem 0 6rem;
gap: 12px;
pointer-events: none; /* só os items recebem clique */
background: linear-gradient(
to right,
var(--m-band) 0%,
var(--m-band) 35%,
transparent 85%
);
}
.melissa-dock > * { pointer-events: auto; }
@@ -2629,6 +2944,13 @@ html:not(.app-dark) .win11-root {
(NÃO usar var(--m-text) aqui em light vira preto e fica preto sobre preto). */
--m-kbd-bg: color-mix(in srgb, var(--p-text-color) 78%, transparent);
--m-kbd-text: var(--p-content-background);
/* Faixa de fundo do topbar/dock em light: branco 80% (vs preto 80% em dark) */
--m-band: rgba(255, 255, 255, 0.8);
/* Fundo dos textos do hero em light: branco 60% (vs preto 60% em dark) */
--m-hero-text-bg: rgba(255, 255, 255, 0.6);
--m-hero-text-border: rgba(0, 0, 0, 0.08);
}
/* Light: dim escondido por padrão (sem foto, bloom claro já é leve).
+8 -3
View File
@@ -32,7 +32,7 @@ const props = defineProps({
secaoAtiva: { type: String, default: null }
});
const emit = defineEmits(['select', 'close']);
const emit = defineEmits(['select', 'close', 'open-settings']);
const router = useRouter();
const { role } = useRoleGuard();
@@ -320,6 +320,12 @@ function goPlano() { emit('select', 'plano'); emit('close'); }
function goNegocio() { emit('select', 'negocio'); emit('close'); }
function goSeguranca() { emit('select', 'seguranca'); emit('close'); }
// "Cores do Tema" virou link pro painel Personalizar (cog top-right):
// fecha o menu e pede pro pai abrir o settings panel. Antes a view de
// tema ficava embutida no proprio mm-aside (themeViewActive) codigo
// daquela view permanece vivo mas inacessivel via UI.
function goCoresTema() { emit('open-settings'); emit('close'); }
// Cores do Tema (embutido no aside, não em popover externo)
const themeViewActive = ref(false);
@@ -486,8 +492,7 @@ async function sair() {
</button>
<button
class="mm-foot-item"
:class="{ 'is-active': themeViewActive }"
@click="toggleThemeView"
@click="goCoresTema"
>
<i class="pi pi-palette" /><span>Cores do Tema</span>
</button>
+428 -59
View File
@@ -14,6 +14,7 @@
* company_profiles + bucket logos), compativel com /account/negocio.
*/
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -29,14 +30,32 @@ const LOGO_BUCKET = 'logos';
// Breakpoints + drawer
const drawerOpen = ref(false);
const isMobile = ref(false);
const isWideDesktop = ref(false); // >= 1340px preview vira painel flutuante fora do fake dialog
let _mqMobile = null;
let _mqWide = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function _onMqWideChange(e) { isWideDesktop.value = e.matches; }
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
// Onde o card de Pre-visualizacao e renderizado:
// - mobile: dentro da sidebar (que e teleportada pro drawer)
// - mid-desktop (1024-1339): dentro da sidebar inline
// - wide-desktop (>=1340): painel flutuante fora do fake dialog
const previewTarget = computed(() => (
isWideDesktop.value && !isMobile.value
? '#mng-floating-preview-target'
: '#mng-sidebar-preview-target'
));
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(true);
const saving = ref(false);
@@ -115,6 +134,41 @@ const negocioInitials = computed(() => {
return (a + b).toUpperCase();
});
// Preview live (cartao de visita)
const logoDisplay = computed(() => ui.logoPreview || form.logo_url || null);
const enderecoLinhas = computed(() => {
const lines = [];
if (form.logradouro) {
let l = form.logradouro;
if (form.numero) l += `, ${form.numero}`;
if (form.complemento) l += `${form.complemento}`;
lines.push(l);
}
if (form.bairro) lines.push(form.bairro);
const ce = [form.cidade, form.estado].filter(Boolean).join(' / ');
if (ce) lines.push(ce);
if (form.cep) lines.push(`CEP ${form.cep}`);
return lines;
});
const redesValidas = computed(() => form.redes_sociais.filter((r) => (r.name || r.rede) && r.url));
const temDados = computed(() => !!(form.nome_fantasia || form.razao_social || logoDisplay.value));
// Tenta detectar a plataforma pelo nome digitado pra escolher o icone certo.
// Funciona com strings freeform tipo "Instagram", "linkedin", "WhatsApp" etc.
function redeIcon(rede) {
const k = String(rede?.name || rede?.rede || '').trim().toLowerCase();
if (!k) return 'pi-link';
if (k.includes('instagram')) return 'pi-instagram';
if (k.includes('facebook')) return 'pi-facebook';
if (k.includes('linkedin')) return 'pi-linkedin';
if (k.includes('twitter') || k === 'x') return 'pi-twitter';
if (k.includes('youtube')) return 'pi-youtube';
if (k.includes('whatsapp') || k.includes('wpp')) return 'pi-whatsapp';
if (k.includes('tiktok')) return 'pi-tiktok';
if (k.includes('telegram')) return 'pi-send';
return 'pi-link';
}
// Gamificação / Progresso
const progressFields = computed(() => [
{ key: 'nome', filled: !!form.nome_fantasia?.trim(), icon: 'pi pi-building', text: 'Preencha o nome do negócio' },
@@ -327,7 +381,14 @@ async function loadNegocio() {
form.site = co.site ?? '';
form.logo_url = co.logo_url ?? '';
ui.logoPreview = form.logo_url;
form.redes_sociais = Array.isArray(co.redes_sociais) ? co.redes_sociais : [];
// Normaliza schemas legados: o antigo cfg-empresa salvava { rede, url }
// (rede = enum), enquanto MelissaNegocio usa { name, url } (name = freeform).
// Mapeia rede -> name quando name vier vazio pra dados antigos nao perderem.
const REDE_LABELS = { instagram: 'Instagram', facebook: 'Facebook', linkedin: 'LinkedIn', twitter: 'X / Twitter', youtube: 'YouTube', tiktok: 'TikTok', whatsapp: 'WhatsApp', telegram: 'Telegram', pinterest: 'Pinterest', outro: 'Outro' };
form.redes_sociais = (Array.isArray(co.redes_sociais) ? co.redes_sociais : []).map((r) => ({
name: r.name || REDE_LABELS[r.rede] || r.rede || '',
url: r.url || ''
}));
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar os dados do negócio.', life: 6000 });
@@ -415,6 +476,11 @@ onMounted(async () => {
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
_mqWide = window.matchMedia('(min-width: 1340px)');
isWideDesktop.value = _mqWide.matches;
try { _mqWide.addEventListener('change', _onMqWideChange); }
catch { _mqWide.addListener(_onMqWideChange); }
}
await tenantStore.ensureLoaded();
await loadNegocio();
@@ -425,6 +491,10 @@ onBeforeUnmount(() => {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
if (_mqWide) {
try { _mqWide.removeEventListener('change', _onMqWideChange); }
catch { _mqWide.removeListener(_onMqWideChange); }
}
clearLogoFile();
});
</script>
@@ -447,6 +517,12 @@ onBeforeUnmount(() => {
/>
</Transition>
<!-- Painel flutuante do Preview (wide-desktop >=1340px). Vive FORA do
fake dialog, ancorado a sua right edge + 14px gap. Em mobile/mid-
desktop, este painel fica oculto via CSS e o preview teleporta pra
dentro da sidebar via #mng-sidebar-preview-target. -->
<aside id="mng-floating-preview-target" class="mng-floating-preview" aria-label="Pré-visualização do negócio"></aside>
<section class="mng-page">
<header class="mng-page__head">
<button
@@ -489,7 +565,15 @@ onBeforeUnmount(() => {
<div class="mng-body">
<Teleport to="#mng-mobile-drawer-target" :disabled="!isMobile">
<aside class="mng-side">
<div class="mng-side__scroll">
<button class="mng-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mng-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mng-side__scroll mng-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mng-side__scroll">
<!-- Card: Sua presença -->
<div class="mng-w mng-w--side">
<div class="mng-w__head">
@@ -617,6 +701,10 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<!-- Target do Teleport do Preview pra modo sidebar (mobile + mid-desktop).
No wide-desktop (>=1340px) o preview teleporta pro #mng-floating-preview-target. -->
<div id="mng-sidebar-preview-target" class="mng-side-preview-target" />
</div>
</aside>
</Teleport>
@@ -907,6 +995,101 @@ onBeforeUnmount(() => {
</div>
</div>
</section>
<!-- Card de Pre-visualizacao (Teleport). O target alterna conforme o
viewport (sidebar embutida vs painel flutuante fora do fake dialog). -->
<Teleport :to="previewTarget">
<div id="mng-sec-preview" class="mng-w mng-w--side mng-w--preview">
<div class="mng-w__head">
<div class="mng-w__icon"><i class="pi pi-id-card" /></div>
<div class="mng-w__title">
<div class="mng-w__title-text">Pré-visualização</div>
<div class="mng-w__sub">Como seu negócio aparece</div>
</div>
</div>
<div class="mng-w__body">
<div class="mng-preview">
<div v-if="!temDados" class="mng-preview__empty">
<i class="pi pi-building" />
<p>Preencha os dados<br />pra ver o preview</p>
</div>
<template v-else>
<div class="mng-preview__logo-wrap">
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="mng-preview__logo" />
<div v-else class="mng-preview__logo-placeholder">
<i class="pi pi-building" />
</div>
</div>
<div class="mng-preview__name-block">
<h2 v-if="form.nome_fantasia" class="mng-preview__name">{{ form.nome_fantasia }}</h2>
<p v-if="form.razao_social" class="mng-preview__razao">{{ form.razao_social }}</p>
<span v-if="form.tipo_empresa" class="mng-preview__tipo">{{ businessTypes.find((t) => t.value === form.tipo_empresa)?.label || form.tipo_empresa }}</span>
</div>
<div v-if="form.cnpj || form.ie || form.im" class="mng-preview__section">
<div class="mng-preview__divider" />
<div class="mng-preview__docs">
<div v-if="form.cnpj" class="mng-preview__doc-row">
<span class="mng-preview__doc-label">CNPJ</span>
<span class="mng-preview__doc-value">{{ form.cnpj }}</span>
</div>
<div v-if="form.ie" class="mng-preview__doc-row">
<span class="mng-preview__doc-label">IE</span>
<span class="mng-preview__doc-value">{{ form.ie }}</span>
</div>
<div v-if="form.im" class="mng-preview__doc-row">
<span class="mng-preview__doc-label">IM</span>
<span class="mng-preview__doc-value">{{ form.im }}</span>
</div>
</div>
</div>
<div v-if="enderecoLinhas.length" class="mng-preview__section">
<div class="mng-preview__divider" />
<div class="mng-preview__info-row">
<i class="pi pi-map-marker mng-preview__info-icon" />
<div class="mng-preview__info-stack">
<span v-for="(linha, i) in enderecoLinhas" :key="i" class="mng-preview__info-text">{{ linha }}</span>
</div>
</div>
</div>
<div v-if="form.email || form.telefone || form.site" class="mng-preview__section">
<div class="mng-preview__divider" />
<div class="mng-preview__info-stack">
<div v-if="form.email" class="mng-preview__info-row">
<i class="pi pi-envelope mng-preview__info-icon" />
<span class="mng-preview__info-text">{{ form.email }}</span>
</div>
<div v-if="form.telefone" class="mng-preview__info-row">
<i class="pi pi-phone mng-preview__info-icon" />
<span class="mng-preview__info-text">{{ form.telefone }}</span>
</div>
<div v-if="form.site" class="mng-preview__info-row">
<i class="pi pi-globe mng-preview__info-icon" />
<span class="mng-preview__info-text mng-preview__info-text--truncate">{{ form.site }}</span>
</div>
</div>
</div>
<div v-if="redesValidas.length" class="mng-preview__section">
<div class="mng-preview__divider" />
<div class="mng-preview__redes">
<a
v-for="(r, i) in redesValidas"
:key="i"
:href="r.url"
target="_blank"
rel="noopener"
class="mng-preview__rede"
:title="r.name || r.rede"
>
<i :class="`pi ${redeIcon(r)}`" />
<span>{{ r.name || r.rede }}</span>
</a>
</div>
</div>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
@@ -1071,6 +1254,50 @@ onBeforeUnmount(() => {
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mng-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mng-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mng-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mng-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mng-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mng-cfg-btn > span { flex: 1; }
.mng-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mng-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mng-main {
flex: 1;
min-width: 0;
@@ -1094,46 +1321,31 @@ onBeforeUnmount(() => {
100% do container, body com overflow-y: auto pra adicionar redes
sem quebrar layout. Mesma logica na sidebar (Sua presença, Logomarca). */
@media (min-width: 1024px) {
.mng-main {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
align-items: start;
align-content: start;
/* Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha pattern de Seguranca/Pagamento/etc:
- 10241012px : full-width (right: 6px) overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%) */
.mng-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
/* Cards do main empilhados em 1-col (era grid 2-col antes).
.mng-main herda flex-direction: column do base cards crescem
com conteudo, sem scroll interno. Espelha pattern de MelissaPerfil/Plano. */
.mng-main > .mng-w {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: auto;
}
/* Cards da sidebar (Sua presença + Logomarca): altura natural,
flex-shrink: 0 evita compressao quando total passa do
.mng-side__scroll (scroll externo). */
.mng-side > .mng-side__scroll > .mng-w--side {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mng-main > .mng-w > .mng-w__body,
.mng-side .mng-w--side > .mng-w__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mng-main > .mng-w > .mng-w__body::-webkit-scrollbar,
.mng-side .mng-w--side > .mng-w__body::-webkit-scrollbar { width: 5px; }
.mng-main > .mng-w > .mng-w__body::-webkit-scrollbar-thumb,
.mng-side .mng-w--side > .mng-w__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mng-main .mng-grid {
grid-template-columns: 1fr;
}
.mng-main .mng-field--half {
grid-column: 1 / -1;
flex-shrink: 0;
height: auto;
}
}
@@ -1443,6 +1655,111 @@ onBeforeUnmount(() => {
.mng-hint { color: var(--m-text-muted); }
.mng-err { color: rgb(220, 38, 38); font-weight: 500; }
/* ═══════ Preview (cartao de visita live) ═══════ */
.mng-preview {
display: flex; flex-direction: column; gap: 10px;
}
.mng-preview__empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 20px 12px;
color: var(--m-text-muted);
text-align: center;
}
.mng-preview__empty > i { font-size: 1.6rem; opacity: 0.4; margin-bottom: 6px; }
.mng-preview__empty > p { font-size: 0.7rem; opacity: 0.6; line-height: 1.3; margin: 0; }
.mng-preview__logo-wrap {
width: 64px; height: 64px;
display: grid; place-items: center;
margin: 0 auto;
border-radius: 10px;
overflow: hidden;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
}
.mng-preview__logo { width: 100%; height: 100%; object-fit: contain; padding: 4px; }
.mng-preview__logo-placeholder { color: var(--m-text-muted); font-size: 1.4rem; }
.mng-preview__name-block { text-align: center; }
.mng-preview__name {
font-size: 0.95rem; font-weight: 700; color: var(--m-text);
margin: 0 0 2px;
word-wrap: break-word;
}
.mng-preview__razao {
font-size: 0.72rem; color: var(--m-text-muted);
margin: 0 0 4px;
word-wrap: break-word;
}
.mng-preview__tipo {
display: inline-block;
font-size: 0.65rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 12%, transparent);
padding: 2px 8px; border-radius: 999px;
}
.mng-preview__divider {
height: 1px;
background: var(--m-border);
margin: 4px 0;
}
.mng-preview__section { display: flex; flex-direction: column; gap: 6px; }
.mng-preview__docs { display: flex; flex-direction: column; gap: 4px; }
.mng-preview__doc-row {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
font-size: 0.72rem;
}
.mng-preview__doc-label {
font-weight: 600; color: var(--m-text-muted);
text-transform: uppercase; letter-spacing: 0.04em;
flex-shrink: 0;
}
.mng-preview__doc-value {
color: var(--m-text);
font-family: 'Segoe UI Mono', 'Consolas', monospace;
text-align: right;
word-break: break-all;
}
.mng-preview__info-stack { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.mng-preview__info-row {
display: flex; align-items: flex-start; gap: 8px;
font-size: 0.72rem;
color: var(--m-text);
}
.mng-preview__info-icon {
color: var(--p-primary-color);
font-size: 0.78rem;
margin-top: 2px;
flex-shrink: 0;
}
.mng-preview__info-text { flex: 1; min-width: 0; line-height: 1.3; word-break: break-word; }
.mng-preview__info-text--truncate {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mng-preview__redes { display: flex; flex-wrap: wrap; gap: 6px; }
.mng-preview__rede {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 9px;
border-radius: 999px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
font-size: 0.7rem; font-weight: 500;
text-decoration: none;
transition: background-color 120ms ease, border-color 120ms ease;
max-width: 100%;
}
.mng-preview__rede:hover {
background: var(--m-bg-soft-hover);
border-color: color-mix(in srgb, var(--p-primary-color) 35%, var(--m-border));
}
.mng-preview__rede > i { color: var(--p-primary-color); font-size: 0.72rem; }
.mng-preview__rede > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ═══════ Empty state (Redes) ═══════ */
.mng-empty {
display: flex;
@@ -1493,23 +1810,6 @@ onBeforeUnmount(() => {
}
.mng-mobile-drawer.is-open { transform: translateX(0); }
.mng-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mng-mobile-drawer__scroll .mng-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mng-mobile-drawer__scroll .mng-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1521,14 +1821,15 @@ onBeforeUnmount(() => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mng-mobile-drawer__scroll .mng-side__scroll::-webkit-scrollbar { width: 5px; }
.mng-mobile-drawer__scroll .mng-side__scroll::-webkit-scrollbar-thumb {
.mng-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mng-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mng-mobile-drawer__scroll .mng-w--side {
margin: 0;
flex-shrink: 0;
/* No mobile a .mng-side e teleportada pra dentro do drawer scroll. */
.mng-mobile-drawer__scroll .mng-side {
width: 100%;
border-right: none;
}
.mng-mobile-drawer__backdrop {
@@ -1547,7 +1848,7 @@ onBeforeUnmount(() => {
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mng-body { flex-direction: column; padding: 0; }
.mng-side { display: none; }
.mng-body > .mng-side { display: none; }
.mng-main { width: 100%; padding: 8px; }
.mng-main .mng-w {
height: auto;
@@ -1562,4 +1863,72 @@ onBeforeUnmount(() => {
.mng-rede { flex-direction: column; gap: 8px; }
.mng-rede .mng-btn--icon { align-self: flex-end; }
}
/* Painel flutuante do Preview (wide-desktop >= 1340px)
Vive FORA do fake dialog, ancorado a sua right edge + 14px gap.
Mantem proporcoes proximas ao painel "Personalizar" (w-72 ~ 288px).
Glass igual ao fake dialog: fundo, blur, borda, radius, sombra. */
.mng-floating-preview {
display: none; /* default: oculto. Wide-desktop ativa via @media abaixo. */
position: absolute;
top: 6px;
/* height segue o conteudo (sem bottom). max-height limita ao mesmo
espaco do fake dialog pra forcar scroll se ficar muito alto. */
max-height: calc(100% - var(--m-dock-h, 76px) - 12px);
width: 288px;
z-index: 39; /* abaixo do mng-page (40) — nao concorre por foco */
overflow-y: auto;
overflow-x: hidden;
/* Sem padding aqui: o card .mng-w--preview interno controla o espaco
e seu __head fica flush com o topo, alinhando com o head do fake dialog. */
padding: 0;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mng-floating-preview::-webkit-scrollbar { width: 5px; }
.mng-floating-preview::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
/* Placeholder do preview na sidebar/drawer: nao introduz wrapper visivel.
Os filhos teleportados se posicionam como flex items diretos do parent
(mng-side ou drawer), herdando o mesmo gap dos outros cards. */
.mng-side-preview-target { display: contents; }
/* Dentro do painel flutuante, o card de preview perde o "card-em-card":
sem fundo/borda/sombra propria (o glass do painel ja faz esse papel). */
.mng-floating-preview > .mng-w--preview {
background: transparent;
border: none;
box-shadow: none;
border-radius: 0;
}
/* __head do card alinha com o __head do fake dialog (padding 14px 18px) */
.mng-floating-preview > .mng-w--preview > .mng-w__head {
border-bottom: 1px solid var(--m-border);
padding: 14px 18px;
}
.mng-floating-preview > .mng-w--preview > .mng-w__body {
padding: 14px 18px;
}
/* Wide-desktop: floating ativo, ancorado a right edge do .mng-page + 14px gap.
.mng-page tem `right: max(6px, min(50%, calc(100% - 1006px)))`, entao seu
right edge esta a `100% - max(...)` do parent-left. O preview comeca 14px
apos isso. 1340px e o piso onde page (1006) + gap (14) + preview (288) +
margem (32) cabem confortavelmente. */
@media (min-width: 1340px) {
.mng-floating-preview {
display: block;
left: calc(100% - max(6px, min(50%, calc(100% - 1006px))) + 14px);
}
/* Placeholder some — preview foi pro painel flutuante */
.mng-side-preview-target { display: none; }
}
</style>
+86 -95
View File
@@ -14,6 +14,7 @@
* Logica espelhada do ConfiguracoesPagamentoPage (tabela payment_settings).
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -35,6 +36,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(true);
const ownerId = ref(null);
@@ -253,7 +259,15 @@ onBeforeUnmount(() => {
<div class="mpg-body">
<Teleport to="#mpg-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpg-side">
<div class="mpg-side__scroll">
<button class="mpg-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mpg-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mpg-side__scroll mpg-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mpg-side__scroll">
<!-- Card: Resumo -->
<div class="mpg-w mpg-w--side">
<div class="mpg-w__head">
@@ -346,7 +360,7 @@ onBeforeUnmount(() => {
{{ cfg.pix_ativo && cfg.pix_chave ? `${pixTipoLabel[cfg.pix_tipo]}: ${cfg.pix_chave}` : 'Pagamento instantâneo via chave Pix' }}
</div>
</div>
<ToggleSwitch v-model="cfg.pix_ativo" />
<ToggleSwitch v-model="cfg.pix_ativo" @update:modelValue="saveCard('pix')" />
</div>
<div v-if="cfg.pix_ativo" class="mpg-w__body">
<div class="mpg-grid-2">
@@ -388,18 +402,6 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<div v-else class="mpg-w__body">
<div class="mpg-card-actions mpg-card-actions--start">
<button
class="mpg-btn"
:disabled="savingCard === 'pix'"
@click="saveCard('pix')"
>
<i :class="savingCard === 'pix' ? 'pi pi-spin pi-spinner' : 'pi pi-times'" />
<span>Salvar como inativo</span>
</button>
</div>
</div>
</div>
<!-- Deposito / TED -->
@@ -417,7 +419,7 @@ onBeforeUnmount(() => {
: 'Transferência bancária ou depósito' }}
</div>
</div>
<ToggleSwitch v-model="cfg.deposito_ativo" />
<ToggleSwitch v-model="cfg.deposito_ativo" @update:modelValue="saveCard('deposito')" />
</div>
<div v-if="cfg.deposito_ativo" class="mpg-w__body">
<div class="mpg-grid-2">
@@ -479,18 +481,6 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<div v-else class="mpg-w__body">
<div class="mpg-card-actions mpg-card-actions--start">
<button
class="mpg-btn"
:disabled="savingCard === 'deposito'"
@click="saveCard('deposito')"
>
<i :class="savingCard === 'deposito' ? 'pi pi-spin pi-spinner' : 'pi pi-times'" />
<span>Salvar como inativo</span>
</button>
</div>
</div>
</div>
<!-- Dinheiro -->
@@ -502,26 +492,13 @@ onBeforeUnmount(() => {
><i class="pi pi-wallet" /></div>
<div class="mpg-w__title">
<div class="mpg-w__title-text">Dinheiro (espécie)</div>
<div class="mpg-w__sub">Pagamento presencial em dinheiro</div>
</div>
<ToggleSwitch v-model="cfg.dinheiro_ativo" />
</div>
<div class="mpg-w__body">
<p class="mpg-text-hint">
{{ cfg.dinheiro_ativo
? 'Aceita pagamento em espécie nas sessões presenciais.'
: 'Não aceita pagamento em espécie.' }}
</p>
<div class="mpg-card-actions">
<button
class="mpg-btn mpg-btn--primary"
:disabled="savingCard === 'dinheiro'"
@click="saveCard('dinheiro')"
>
<i :class="savingCard === 'dinheiro' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar dinheiro</span>
</button>
<div class="mpg-w__sub">
{{ cfg.dinheiro_ativo
? 'Aceita pagamento em espécie nas sessões presenciais.'
: 'Não aceita pagamento em espécie.' }}
</div>
</div>
<ToggleSwitch v-model="cfg.dinheiro_ativo" @update:modelValue="saveCard('dinheiro')" />
</div>
</div>
@@ -536,7 +513,7 @@ onBeforeUnmount(() => {
<div class="mpg-w__title-text">Cartão (maquininha)</div>
<div class="mpg-w__sub">Crédito e débito presencial</div>
</div>
<ToggleSwitch v-model="cfg.cartao_ativo" />
<ToggleSwitch v-model="cfg.cartao_ativo" @update:modelValue="saveCard('cartao')" />
</div>
<div v-if="cfg.cartao_ativo" class="mpg-w__body">
<div class="mpg-field">
@@ -560,18 +537,6 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<div v-else class="mpg-w__body">
<div class="mpg-card-actions mpg-card-actions--start">
<button
class="mpg-btn"
:disabled="savingCard === 'cartao'"
@click="saveCard('cartao')"
>
<i :class="savingCard === 'cartao' ? 'pi pi-spin pi-spinner' : 'pi pi-times'" />
<span>Salvar como inativo</span>
</button>
</div>
</div>
</div>
<!-- Convênio -->
@@ -589,7 +554,7 @@ onBeforeUnmount(() => {
: 'Atendimento por convênio' }}
</div>
</div>
<ToggleSwitch v-model="cfg.convenio_ativo" />
<ToggleSwitch v-model="cfg.convenio_ativo" @update:modelValue="saveCard('convenio')" />
</div>
<div v-if="cfg.convenio_ativo" class="mpg-w__body">
<div class="mpg-field">
@@ -616,18 +581,6 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<div v-else class="mpg-w__body">
<div class="mpg-card-actions mpg-card-actions--start">
<button
class="mpg-btn"
:disabled="savingCard === 'convenio'"
@click="saveCard('convenio')"
>
<i :class="savingCard === 'convenio' ? 'pi pi-spin pi-spinner' : 'pi pi-times'" />
<span>Salvar como inativo</span>
</button>
</div>
</div>
</div>
<!-- Observações -->
@@ -800,6 +753,50 @@ onBeforeUnmount(() => {
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mpg-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mpg-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mpg-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mpg-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mpg-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mpg-cfg-btn > span { flex: 1; }
.mpg-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mpg-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mpg-main {
flex: 1;
min-width: 0;
@@ -823,6 +820,16 @@ onBeforeUnmount(() => {
pq aqui tambem o conteudo varia muito). flex-shrink: 0 evita
compressao quando o total passa do .mpg-main; scroll externo. */
@media (min-width: 1024px) {
/* Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha o pattern de MelissaSeguranca:
- 1024px1012px : full-width (right: 6px) overlap minimo
- 1012px2012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%)
Formula max-min-calc resolve os 3 casos numa expressao so. */
.mpg-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
.mpg-main > .mpg-w,
.mpg-side > .mpg-side__scroll > .mpg-w--side {
flex-shrink: 0;
@@ -1068,23 +1075,6 @@ onBeforeUnmount(() => {
}
.mpg-mobile-drawer.is-open { transform: translateX(0); }
.mpg-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mpg-mobile-drawer__scroll .mpg-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mpg-mobile-drawer__scroll .mpg-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1096,14 +1086,15 @@ onBeforeUnmount(() => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpg-mobile-drawer__scroll .mpg-side__scroll::-webkit-scrollbar { width: 5px; }
.mpg-mobile-drawer__scroll .mpg-side__scroll::-webkit-scrollbar-thumb {
.mpg-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mpg-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpg-mobile-drawer__scroll .mpg-w--side {
margin: 0;
flex-shrink: 0;
/* No mobile a .mpg-side e teleportada pra dentro do drawer scroll. */
.mpg-mobile-drawer__scroll .mpg-side {
width: 100%;
border-right: none;
}
.mpg-mobile-drawer__backdrop {
@@ -1122,7 +1113,7 @@ onBeforeUnmount(() => {
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mpg-body { flex-direction: column; padding: 0; }
.mpg-side { display: none; }
.mpg-body > .mpg-side { display: none; }
.mpg-main { width: 100%; padding: 8px; }
.mpg-main .mpg-w {
height: auto;
+135 -86
View File
@@ -21,7 +21,7 @@ import { useConfirm } from 'primevue/useconfirm';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import MelissaConfigSidebar from './MelissaConfigSidebar.vue';
import MelissaConfigList from './MelissaConfigList.vue';
// InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver
const emit = defineEmits(['close']);
@@ -44,6 +44,12 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards contextuais (default) e lista de configs (alterna
// inline na .mpr-side via v-if sem overlay/popover, zero overhead).
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(true);
const saving = ref(false);
@@ -465,21 +471,17 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- Drawer mobile: menu global de configs no topo + sidebar
contextual da pagina (Sua evolucao + Avatar) abaixo via
Teleport target. -->
<!-- Drawer mobile: hospeda a sidebar contextual da pagina (Sua
evolucao + Avatar + Sair) via Teleport target. A navegacao
entre configs vive no botao "Configuracoes" no topo da
.mpr-side, que abre o MelissaConfigPopover. -->
<Transition name="mpr-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpr-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div class="mpr-mobile-drawer__scroll">
<div class="mpr-mobile-drawer__configs">
<MelissaConfigSidebar />
</div>
<div id="mpr-mobile-drawer-target" class="mpr-mobile-drawer__contextual" />
</div>
<div id="mpr-mobile-drawer-target" class="mpr-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mpr-drawer-fade">
@@ -535,7 +537,25 @@ onBeforeUnmount(() => {
drawer "Menu Perfil" (abaixo do menu de configs). -->
<Teleport to="#mpr-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpr-side">
<div class="mpr-side__scroll">
<!-- Botao alterna entre cards (default) e lista
de configs. Click vira "Voltar" quando aberto. -->
<button
class="mpr-cfg-btn"
:class="{ 'is-open': cfgOpen }"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mpr-cfg-btn__chev" />
</button>
<!-- Estado: lista de configs -->
<div v-if="cfgOpen" class="mpr-side__scroll mpr-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<!-- Estado: cards contextuais (default) -->
<div v-else class="mpr-side__scroll">
<!-- Card: Sua evolução -->
<div class="mpr-w mpr-w--side">
<div class="mpr-w__head">
@@ -667,7 +687,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="mpr-side__footer">
<div v-if="!cfgOpen" class="mpr-side__footer">
<button class="mpr-btn mpr-btn--danger mpr-btn--full" @click="confirmSignOut">
<i class="pi pi-sign-out" />
<span>Sair da conta</span>
@@ -923,7 +943,7 @@ onBeforeUnmount(() => {
/* ═══════ Page chrome ═══════ */
.mpr-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
z-index: 40;
display: flex;
flex-direction: column;
@@ -1070,6 +1090,55 @@ onBeforeUnmount(() => {
overflow: hidden;
}
/* Botao "Configuracoes" no topo da .mpr-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mpr-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mpr-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mpr-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mpr-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mpr-cfg-btn > span {
flex: 1;
}
.mpr-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
/* Variante do scroll quando renderiza a lista de configs em vez
dos cards sem gap entre items (a propria lista controla). */
.mpr-side__scroll--cfg {
padding: 8px;
gap: 0;
}
/* COL 1: sidebar */
.mpr-side {
width: 320px;
@@ -1131,46 +1200,31 @@ onBeforeUnmount(() => {
interno engata. Mesma logica nos cards da sidebar (Sua evolucao,
Avatar) que cresciam quando muitas badges/dicas apareciam. */
@media (min-width: 1024px) {
.mpr-main {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
align-items: start;
align-content: start;
/* Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha pattern de Seguranca/Pagamento/etc:
- 10241012px : full-width (right: 6px) overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%) */
.mpr-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
/* Cards do main empilhados em 1-col (era grid 2-col antes).
.mpr-main herda flex-direction: column do base cards crescem
com conteudo, sem scroll interno. Cada card ocupa width total
do dialog (max ~1000px). */
.mpr-main > .mpr-w {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: auto;
}
/* Cards da sidebar (Sua evolucao + Avatar): altura natural,
flex-shrink: 0 evita compressao quando total passa do
.mpr-side__scroll (scroll externo). */
.mpr-side > .mpr-side__scroll > .mpr-w--side {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mpr-main > .mpr-w > .mpr-w__body,
.mpr-side .mpr-w--side > .mpr-w__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpr-main > .mpr-w > .mpr-w__body::-webkit-scrollbar,
.mpr-side .mpr-w--side > .mpr-w__body::-webkit-scrollbar { width: 5px; }
.mpr-main > .mpr-w > .mpr-w__body::-webkit-scrollbar-thumb,
.mpr-side .mpr-w--side > .mpr-w__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpr-main .mpr-grid {
grid-template-columns: 1fr;
}
.mpr-main .mpr-field--half {
grid-column: 1 / -1;
flex-shrink: 0;
height: auto;
}
}
@@ -1528,6 +1582,28 @@ onBeforeUnmount(() => {
}
.mpr-mobile-drawer.is-open { transform: translateX(0); }
.mpr-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Aside teleportada pro drawer — perde adornos proprios */
.mpr-mobile-drawer__scroll .mpr-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mpr-mobile-drawer__scroll .mpr-cfg-btn {
margin: 12px 12px 0;
flex-shrink: 0;
}
.mpr-mobile-drawer__scroll .mpr-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1539,47 +1615,21 @@ onBeforeUnmount(() => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpr-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mpr-mobile-drawer__scroll::-webkit-scrollbar-thumb {
.mpr-mobile-drawer__scroll .mpr-side__scroll::-webkit-scrollbar { width: 5px; }
.mpr-mobile-drawer__scroll .mpr-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpr-mobile-drawer__configs {
.mpr-mobile-drawer__scroll .mpr-w--side {
margin: 0;
flex-shrink: 0;
}
.mpr-mobile-drawer__configs :deep(.mcs-aside) {
height: auto;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
}
.mpr-mobile-drawer__configs :deep(.mcs-aside__body) {
max-height: none;
overflow: visible;
}
.mpr-mobile-drawer__contextual {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.mpr-mobile-drawer__contextual .mpr-side {
width: 100%;
background: transparent;
border-right: none;
flex-shrink: 0;
}
.mpr-mobile-drawer__contextual .mpr-side__scroll {
padding: 0;
gap: 12px;
overflow: visible;
}
.mpr-mobile-drawer__contextual .mpr-side__footer {
padding: 0;
background: transparent;
border-top: none;
}
.mpr-mobile-drawer__contextual .mpr-w--side {
.mpr-mobile-drawer__scroll .mpr-side__footer {
flex-shrink: 0;
margin: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
}
.mpr-mobile-drawer__backdrop {
@@ -1601,10 +1651,9 @@ onBeforeUnmount(() => {
flex-direction: column;
padding: 0;
}
/* Sidebar contextual sai do flow (vai pro drawer via Teleport) */
.mpr-side {
display: none;
}
/* .mpr-side foi Teleportada pro drawer nao precisa de display: none
(Teleport ja a removeu do .mpr-body). Aplicar display: none aqui
escondia o elemento teleportado dentro do drawer. */
.mpr-main {
width: 100%;
padding: 8px;
+88 -60
View File
@@ -15,6 +15,7 @@
* + profiles).
*/
import { ref, computed, onMounted } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
@@ -38,6 +39,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const loading = ref(false);
const hasLoaded = ref(false);
@@ -422,7 +428,15 @@ onMounted(async () => {
<!-- COL 1: Sidebar -->
<Teleport to="#mpl-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpl-side">
<div class="mpl-side__scroll">
<button class="mpl-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mpl-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mpl-side__scroll mpl-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mpl-side__scroll">
<!-- Card: Plano atual -->
<div class="mpl-w mpl-w--side">
<div class="mpl-w__head">
@@ -530,7 +544,7 @@ onMounted(async () => {
</div>
</div>
<div class="mpl-side__footer">
<div v-if="!cfgOpen" class="mpl-side__footer">
<button class="mpl-btn mpl-btn--primary mpl-btn--full" @click="goUpgrade">
<i class="pi pi-arrow-up-right" />
<span>{{ subscription ? 'Alterar plano' : 'Ver planos' }}</span>
@@ -829,6 +843,50 @@ onMounted(async () => {
border-top: 1px solid var(--m-border);
}
/* Botao "Configuracoes" no topo da .mpl-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mpl-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mpl-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mpl-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mpl-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mpl-cfg-btn > span { flex: 1; }
.mpl-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mpl-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mpl-main {
flex: 1;
min-width: 0;
@@ -852,40 +910,30 @@ onMounted(async () => {
+ body com overflow-y: auto. Sidebar (Plano atual + Resumo) ganha
o mesmo cap pra nao crescer demais quando tem features/eventos longos. */
@media (min-width: 1024px) {
.mpl-main {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
align-items: start;
align-content: start;
/* Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha pattern de Seguranca/Pagamento/etc:
- 10241012px : full-width (right: 6px) overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%) */
.mpl-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
/* Cards do main empilhados em 1-col (era grid 2-col antes).
.mpl-main herda flex-direction: column do base cards crescem
com conteudo, sem scroll interno. Espelha pattern de MelissaPerfil. */
.mpl-main > .mpl-w {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: auto;
}
/* Cards da sidebar (Plano atual + Resumo): altura natural,
flex-shrink: 0 evita compressao quando total passa do
.mpl-side__scroll (scroll externo). */
.mpl-side > .mpl-side__scroll > .mpl-w--side {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mpl-main > .mpl-w > .mpl-w__body,
.mpl-side .mpl-w--side > .mpl-w__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpl-main > .mpl-w > .mpl-w__body::-webkit-scrollbar,
.mpl-side .mpl-w--side > .mpl-w__body::-webkit-scrollbar { width: 5px; }
.mpl-main > .mpl-w > .mpl-w__body::-webkit-scrollbar-thumb,
.mpl-side .mpl-w--side > .mpl-w__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
flex-shrink: 0;
height: auto;
}
}
@@ -1298,23 +1346,6 @@ onMounted(async () => {
}
.mpl-mobile-drawer.is-open { transform: translateX(0); }
.mpl-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mpl-mobile-drawer__scroll .mpl-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mpl-mobile-drawer__scroll .mpl-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -1326,21 +1357,16 @@ onMounted(async () => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpl-mobile-drawer__scroll .mpl-side__scroll::-webkit-scrollbar { width: 5px; }
.mpl-mobile-drawer__scroll .mpl-side__scroll::-webkit-scrollbar-thumb {
.mpl-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mpl-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpl-mobile-drawer__scroll .mpl-w--side {
margin: 0;
flex-shrink: 0;
}
.mpl-mobile-drawer__scroll .mpl-side__footer {
flex-shrink: 0;
margin: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
/* No mobile a .mpl-side e teleportada pra dentro do drawer scroll.
Reset pra ela ocupar largura total e sem border-right. */
.mpl-mobile-drawer__scroll .mpl-side {
width: 100%;
border-right: none;
}
.mpl-mobile-drawer__backdrop {
@@ -1362,7 +1388,9 @@ onMounted(async () => {
flex-direction: column;
padding: 0;
}
.mpl-side { display: none; }
/* No mobile a .mpl-side e teleportada pro drawer; oculta a copia
inline (caso a teleport target nao monte por algum motivo). */
.mpl-body > .mpl-side { display: none; }
.mpl-main {
width: 100%;
padding: 8px;
+503
View File
@@ -0,0 +1,503 @@
<script setup>
/*
* MelissaPrecificacao Pagina nativa Melissa pra "Precificacao".
*
* Substitui o embed cfg-precificacao que vivia dentro do MelissaConfiguracoes.
* Layout 2-col:
* - COL 1 (sidebar) Botao "Configuracoes" (alterna pra MelissaConfigList)
* + Card "Como funciona" (FAQ explicando tipos de servico e preco)
* - COL 2 (main) Reusa <ConfiguracoesPrecificacaoPage /> direto pra
* evitar duplicar a logica de tabela de servicos (~400 linhas).
*
* Quando aprovado o pattern visual, replica nos outros 16 cfg-* embeds.
* Pra essa primeira iteracao, o main reusa a page existente alternativa
* cleaner seria mover a logica pra ca, mas fica pra refator dedicado.
*/
import { ref, onMounted, onBeforeUnmount } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import ConfiguracoesPrecificacaoPage from '@/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue';
const emit = defineEmits(['close']);
// Breakpoints + drawer
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<Transition name="mpc-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpc-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mpc-mobile-drawer-target" class="mpc-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mpc-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpc-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mpc-page">
<header class="mpc-page__head">
<button
class="mpc-menu-btn mpc-menu-btn--mobile-only"
v-tooltip.bottom="'Configurações'"
aria-label="Abrir menu de configurações"
@click="toggleDrawer"
>
<i class="pi pi-bars" aria-hidden="true" />
<span>Menu</span>
</button>
<div class="mpc-page__title">
<i class="pi pi-tag mpc-page__title-icon" aria-hidden="true" />
<span>Precificação</span>
</div>
<div class="mpc-page__actions">
<button
class="mpc-close"
v-tooltip.bottom="'Voltar (Esc)'"
aria-label="Voltar (Esc)"
@click="emit('close')"
>
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
</header>
<div class="mpc-subheader" role="note">
<i class="pi pi-info-circle mpc-subheader__icon" aria-hidden="true" />
<span class="mpc-subheader__text">
Defina o valor padrão da sessão e os preços por tipo de compromisso.
Esses valores aparecem ao agendar e cobrar pacientes.
</span>
</div>
<div class="mpc-body">
<Teleport to="#mpc-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpc-side">
<button
class="mpc-cfg-btn"
:class="{ 'is-open': cfgOpen }"
@click="toggleCfg"
>
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" aria-hidden="true" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mpc-cfg-btn__chev" aria-hidden="true" />
</button>
<div v-if="cfgOpen" class="mpc-side__scroll mpc-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mpc-side__scroll">
<!-- Card: Como funciona -->
<div class="mpc-w mpc-w--side">
<div class="mpc-w__head">
<div class="mpc-w__icon"><i class="pi pi-question-circle" aria-hidden="true" /></div>
<div class="mpc-w__title">
<div class="mpc-w__title-text">Como funciona</div>
<div class="mpc-w__sub">Tipos de serviço e preços</div>
</div>
</div>
<div class="mpc-w__body">
<ul class="mpc-faq">
<li class="mpc-faq__item">
<i class="pi pi-tag" aria-hidden="true" />
<span><strong>Serviço:</strong> nome (ex: Consulta inicial), preço e duração.</span>
</li>
<li class="mpc-faq__item">
<i class="pi pi-clock" aria-hidden="true" />
<span><strong>Duração dinâmica:</strong> habilitada nas configs da agenda; cada serviço pode ter duração própria.</span>
</li>
<li class="mpc-faq__item">
<i class="pi pi-eye-slash" aria-hidden="true" />
<span><strong>Inativar:</strong> mantém o histórico mas remove dos formulários novos.</span>
</li>
</ul>
</div>
</div>
</div>
</aside>
</Teleport>
<div class="mpc-main">
<ConfiguracoesPrecificacaoPage />
</div>
</div>
</section>
</template>
<style scoped>
/* ═══════ Page chrome ═══════ */
.mpc-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mpc-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mpc-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mpc-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mpc-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 700;
color: var(--m-text);
flex-wrap: wrap;
}
.mpc-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mpc-page__actions { display: flex; align-items: center; gap: 6px; }
.mpc-close {
width: 32px; height: 32px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mpc-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mpc-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mpc-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mpc-subheader {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mpc-subheader__icon {
color: var(--p-primary-color);
font-size: 0.85rem;
margin-top: 2px;
flex-shrink: 0;
}
/* ═══════ Body 2-col ═══════ */
.mpc-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.mpc-side {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mpc-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpc-side__scroll::-webkit-scrollbar { width: 5px; }
.mpc-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da sidebar — alterna entre cards e lista. */
.mpc-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mpc-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mpc-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mpc-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mpc-cfg-btn > span { flex: 1; }
.mpc-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mpc-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mpc-main {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpc-main::-webkit-scrollbar { width: 5px; }
.mpc-main::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Desktop: fake dialog (largura adaptativa) espelha pattern de
Seguranca/Pagamento/etc. Sidebar cards com altura por conteudo. */
@media (min-width: 1024px) {
.mpc-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
.mpc-side > .mpc-side__scroll > .mpc-w--side {
flex-shrink: 0;
height: auto;
display: flex;
flex-direction: column;
}
}
/* ═══════ Card-base sidebar ═══════ */
.mpc-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
}
.mpc-w--side {
background: var(--m-bg-medium);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.mpc-w__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mpc-w__icon {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mpc-w__icon > i { font-size: 0.95rem; }
.mpc-w__title { flex: 1; min-width: 0; }
.mpc-w__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
line-height: 1.2;
}
.mpc-w__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mpc-w__body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ═══════ FAQ list (sidebar Como funciona) ═══════ */
.mpc-faq {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin: 0;
padding: 0;
}
.mpc-faq__item {
display: flex;
gap: 10px;
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mpc-faq__item > i {
color: var(--p-primary-color);
font-size: 0.78rem;
margin-top: 3px;
flex-shrink: 0;
}
.mpc-faq__item strong {
color: var(--m-text);
font-weight: 600;
}
/* ═══════ Mobile drawer ═══════ */
.mpc-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.mpc-mobile-drawer.is-open { transform: translateX(0); }
.mpc-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpc-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mpc-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpc-mobile-drawer__scroll .mpc-side {
width: 100%;
border-right: none;
}
.mpc-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mpc-drawer-fade-enter-active,
.mpc-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mpc-drawer-fade-enter-from,
.mpc-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mpc-body { flex-direction: column; padding: 0; }
.mpc-body > .mpc-side { display: none; }
.mpc-main { width: 100%; padding: 8px; }
.mpc-page__title > span:first-of-type { display: none; }
.mpc-page__title-icon { display: none; }
.mpc-menu-btn--mobile-only { display: inline-flex; }
}
</style>
+66 -24
View File
@@ -14,6 +14,7 @@
* apos sucesso por seguranca; reset por email opcional).
*/
import { ref, computed, onMounted } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
// InputText/Password/Button: auto via PrimeVueResolver
@@ -33,6 +34,11 @@ function _onMqMobileChange(e) {
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// Estado
const userEmail = ref('');
const loading = ref(false);
@@ -232,7 +238,15 @@ onMounted(async () => {
<div class="mse-body">
<Teleport to="#mse-mobile-drawer-target" :disabled="!isMobile">
<aside class="mse-side">
<div class="mse-side__scroll">
<button class="mse-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mse-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mse-side__scroll mse-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mse-side__scroll">
<!-- Card: Estado da conta -->
<div class="mse-w mse-w--side">
<div class="mse-w__head">
@@ -556,6 +570,50 @@ onMounted(async () => {
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mse-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mse-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mse-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mse-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mse-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mse-cfg-btn > span { flex: 1; }
.mse-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mse-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mse-main {
flex: 1;
min-width: 0;
@@ -870,23 +928,6 @@ onMounted(async () => {
}
.mse-mobile-drawer.is-open { transform: translateX(0); }
.mse-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mse-mobile-drawer__scroll .mse-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mse-mobile-drawer__scroll .mse-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -898,14 +939,15 @@ onMounted(async () => {
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mse-mobile-drawer__scroll .mse-side__scroll::-webkit-scrollbar { width: 5px; }
.mse-mobile-drawer__scroll .mse-side__scroll::-webkit-scrollbar-thumb {
.mse-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mse-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mse-mobile-drawer__scroll .mse-w--side {
margin: 0;
flex-shrink: 0;
/* No mobile a .mse-side e teleportada pra dentro do drawer scroll. */
.mse-mobile-drawer__scroll .mse-side {
width: 100%;
border-right: none;
}
.mse-mobile-drawer__backdrop {
@@ -971,7 +1013,7 @@ onMounted(async () => {
/* ═══════ Mobile (<768px) ═══════ */
@media (max-width: 767px) {
.mse-body { flex-direction: column; padding: 0; }
.mse-side { display: none; }
.mse-body > .mse-side { display: none; }
.mse-main { width: 100%; padding: 8px; }
.mse-main .mse-w {
height: auto;
+159 -4
View File
@@ -18,8 +18,9 @@
* Emit:
* - close botao X clicado
*/
import { inject, ref } from 'vue';
import { inject, ref, computed } from 'vue';
import { TOQUES } from './melissaToques';
import { MELISSA_THEMES } from './melissaThemes';
const emit = defineEmits(['close']);
@@ -49,7 +50,11 @@ const {
clearBg,
use24h,
toqueTermino,
testarToque
testarToque,
themeName,
setThemeName,
textBgEnabled,
setTextBgEnabled
} = settings || {};
// Cola de DOM: input <type=file> escondido + trigger via botao "Trocar
@@ -59,6 +64,64 @@ const fileInput = ref(null);
function pickFile() {
fileInput.value?.click();
}
// Tema ativo: prioriza themeName persistido (se nao bate com nenhum,
// cai em heuristica primary+preset+surface). Persistimos so o nome
// no DB (melissa_prefs.themeName); na rehidratacao o MelissaLayout
// resolve o nome -> imagem e seta bgUrl.
const activeTheme = computed(() => {
if (themeName?.value) {
const persisted = MELISSA_THEMES.find((t) => t.name === themeName.value);
if (persisted) return persisted.name;
}
return MELISSA_THEMES.find((t) =>
layoutConfig?.primary === t.primary &&
layoutConfig?.preset === t.preset &&
layoutConfig?.surface === t.surface
)?.name || null;
});
async function applyTheme(t) {
// 1. Persiste o NOME do tema (DB + localStorage). MelissaLayout usa
// isso pra restaurar o wallpaper em outro device sem precisar
// guardar a data URL gigante no DB.
setThemeName?.(t.name);
// 2. Wallpaper na sessao corrente: converte pra data URL pra sobreviver
// a reload local (localStorage so aceita data: URLs por seguranca).
try {
const res = await fetch(t.image);
const blob = await res.blob();
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = reject;
r.readAsDataURL(blob);
});
bgUrl.value = dataUrl;
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[Temas] falha ao converter wallpaper em data URL:', e?.message);
bgUrl.value = t.image; // fallback: URL direta (funciona na sessao)
}
// 3. Cor primaria, preset e surface ja persistem direto em colunas
// proprias de user_settings via queuePatch.
setPrimary?.(t.primary);
setPreset?.(t.preset);
setSurface?.(t.surface);
}
// Wrappers: ao escolher imagem custom OU voltar ao padrao, o tema
// previamente selecionado deixa de fazer sentido limpa themeName.
function onCustomFileChange(e) {
setThemeName?.(null);
onFileChange?.(e);
}
function onClearBg() {
setThemeName?.(null);
clearBg?.();
}
</script>
<template>
@@ -81,6 +144,24 @@ function pickFile() {
<!-- Corpo scrollavel -->
<div class="mp-body">
<!-- Temas (bundle wallpaper + cores) -->
<div class="mp-divider"><span>Temas</span></div>
<div class="mp-themes">
<button
v-for="t in MELISSA_THEMES"
:key="t.name"
type="button"
class="mp-theme"
:class="{ 'is-active': activeTheme === t.name }"
:title="`Aplicar tema ${t.name}`"
:aria-pressed="activeTheme === t.name"
@click="applyTheme(t)"
>
<div class="mp-theme__thumb" :style="{ backgroundImage: `url(${t.image})` }" />
<span class="mp-theme__name">{{ t.name }}</span>
</button>
</div>
<!-- Plano de fundo -->
<div class="mp-divider"><span>Plano de Fundo</span></div>
@@ -94,12 +175,12 @@ function pickFile() {
<button
v-if="bgUrl"
class="mp-btn mp-btn--ghost"
@click="clearBg"
@click="onClearBg"
>
<i class="pi pi-refresh" />
<span>Voltar ao padrão</span>
</button>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
<input ref="fileInput" type="file" accept="image/*" hidden @change="onCustomFileChange" />
<div v-if="bgUrl" class="mp-field">
<label class="mp-label">
@@ -131,6 +212,27 @@ function pickFile() {
/>
</div>
<!-- Toggle: fundo solido nos textos do hero (relogio/saudacao/resumo).
Util quando o wallpaper tem pouca transparencia e o text-shadow
nao da legibilidade suficiente. -->
<div class="mp-row">
<span class="mp-row__label">
Fundo nos textos
<span class="mp-row__sub">(relógio &amp; saudação)</span>
</span>
<button
class="settings-toggle w-10 h-6 rounded-full transition-colors relative"
:class="textBgEnabled ? 'is-on' : 'bg-white/20'"
:title="textBgEnabled ? 'Remover fundo dos textos' : 'Adicionar fundo aos textos'"
@click="setTextBgEnabled(!textBgEnabled)"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: textBgEnabled ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<!-- Relogio & Toques -->
<div class="mp-divider"><span>Relógio &amp; Som</span></div>
@@ -534,4 +636,57 @@ function pickFile() {
0 0 0 2px var(--m-bg-medium),
0 0 0 4px var(--p-primary-color);
}
/* Temas (cards de wallpaper)
Grid 3-col com thumb quadrado + label. Click aplica wallpaper
+ cor primaria + preset + surface de uma vez. */
.mp-themes {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.mp-theme {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 4px;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
font: inherit;
color: var(--m-text);
}
.mp-theme:hover {
background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.06));
border-color: var(--m-border);
}
.mp-theme:active { transform: scale(0.97); }
.mp-theme.is-active {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: var(--p-primary-color);
}
.mp-theme__thumb {
aspect-ratio: 1;
border-radius: 7px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border: 1px solid var(--m-border);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
.mp-theme.is-active .mp-theme__thumb {
border-color: var(--p-primary-color);
}
.mp-theme__name {
font-size: 0.7rem;
font-weight: 600;
text-align: center;
letter-spacing: 0.02em;
color: var(--m-text);
line-height: 1;
}
</style>
@@ -12,15 +12,10 @@
* plano, agenda-config, pagamento, etc), e pagina nativa Melissa.
*/
export const MELISSA_CONFIG_GRUPOS = [
{
key: 'layout-melissa',
label: 'Layout Melissa',
desc: 'Aparência, plano de fundo, relógio e cronômetro do resumo.',
icon: 'pi pi-palette',
items: [
{ key: 'aparencia', label: 'Layout Melissa', desc: 'Tema, cor primária, surface, plano de fundo, relógio e cronômetro — tudo numa tela só.', icon: 'pi pi-palette' }
]
},
// "Layout Melissa" (aparencia) removido — esses controles
// (tema, cor primaria, surface, fundo, relogio, cronometro)
// sao acessados pela primeira pagina (resumo), pra que o user
// veja as alteracoes em tempo real no proprio canvas.
{
key: 'conta',
label: 'Conta',
@@ -86,13 +81,13 @@ export const MELISSA_CONFIG_GRUPOS = [
},
{
key: 'plataforma',
label: 'Empresa & Plataforma',
desc: 'Dados da empresa, recursos extras e auditoria.',
icon: 'pi pi-building',
label: 'Plataforma',
desc: 'Recursos extras e auditoria.',
icon: 'pi pi-server',
items: [
{ key: 'cfg-empresa', label: 'Minha Empresa', desc: 'CNPJ, endereço, logomarca e redes sociais.', icon: 'pi pi-building' },
{ key: 'cfg-recursos-extras', label: 'Recursos Extras', desc: 'Amplíe as funcionalidades com recursos adicionais.', icon: 'pi pi-box' },
{ key: 'cfg-auditoria', label: 'Auditoria', desc: 'Registro imutável de operações (LGPD Art. 37).', icon: 'pi pi-shield' }
{ key: 'cfg-recursos-extras', label: 'Recursos Extras', desc: 'Amplíe as funcionalidades com recursos adicionais.', icon: 'pi pi-box' },
{ key: 'cfg-recursos-extras-extrato', label: 'Extrato de Recursos Extras', desc: 'Histórico de débitos e créditos exportável (CSV/PDF).', icon: 'pi pi-list' },
{ key: 'cfg-auditoria', label: 'Auditoria', desc: 'Registro imutável de operações (LGPD Art. 37).', icon: 'pi pi-shield' }
]
}
];
+28
View File
@@ -0,0 +1,28 @@
/*
* melissaThemes catalogo dos "Temas" do Melissa.
*
* Cada tema bundla wallpaper + cor primaria + preset (Lara/Nora) + surface.
* Usado por:
* - MelissaSettingsPanel: renderiza os cards e aplica via applyTheme
* - MelissaLayout: resolve themeName -> image no boot pra restaurar
* o wallpaper sem precisar guardar a data URL inteira no DB
*
* Adicionar um novo tema: estender o array com { name, image, primary,
* preset, surface }. O nome serve como ID persistido em melissa_prefs.
*/
import freudImg from '@/assets/themes/freudwebp.webp';
import kleinImg from '@/assets/themes/melainewebp.webp';
import jungImg from '@/assets/themes/jungwebp.webp';
export const MELISSA_THEMES = Object.freeze([
{ name: 'Freud', image: freudImg, primary: 'noir', preset: 'Lara', surface: 'slate' },
{ name: 'Klein', image: kleinImg, primary: 'rose', preset: 'Nora', surface: 'stone' },
{ name: 'Jung', image: jungImg, primary: 'purple', preset: 'Nora', surface: 'zinc' }
]);
export const MELISSA_THEME_NAMES = new Set(MELISSA_THEMES.map((t) => t.name));
export function findMelissaTheme(name) {
if (!name) return null;
return MELISSA_THEMES.find((t) => t.name === name) || null;
}