9966b5f175
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>
1212 lines
65 KiB
Vue
1212 lines
65 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| Agência PSI
|
||
|--------------------------------------------------------------------------
|
||
| Criado e desenvolvido por Leonardo Nohama
|
||
|
|
||
| Tecnologia aplicada à escuta.
|
||
| Estrutura para o cuidado.
|
||
|
|
||
| Arquivo: src/layout/configuracoes/ConfiguracoesAgendadorPage.vue
|
||
| Data: 2026
|
||
| Local: São Carlos/SP — Brasil
|
||
|--------------------------------------------------------------------------
|
||
| © 2026 — Todos os direitos reservados
|
||
|--------------------------------------------------------------------------
|
||
-->
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue';
|
||
import { RouterLink, useRoute } from 'vue-router';
|
||
import { supabase } from '@/lib/supabase/client';
|
||
import { useTenantStore } from '@/stores/tenantStore';
|
||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||
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'));
|
||
|
||
// ── Estado ─────────────────────────────────────────────────────
|
||
const loading = ref(true);
|
||
const ownerId = ref(null);
|
||
const expandedCard = ref(new Set());
|
||
const savingCard = ref(null);
|
||
|
||
// ── Upload de imagens ────────────────────────────────────────────
|
||
const AGENDADOR_BUCKET = 'agendador';
|
||
const uploadingField = ref(null); // 'logomarca' | 'header' | 'fundo'
|
||
const fileInputLogo = ref(null);
|
||
const fileInputHeader = ref(null);
|
||
const fileInputFundo = ref(null);
|
||
|
||
async function uploadImagem(file, field) {
|
||
if (!file || !ownerId.value) return null;
|
||
uploadingField.value = field;
|
||
try {
|
||
const ext = file.name.split('.').pop() || 'jpg';
|
||
const path = `${ownerId.value}/${field}-${Date.now()}.${ext}`;
|
||
const { error: upErr } = await supabase.storage.from(AGENDADOR_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
|
||
if (upErr) throw upErr;
|
||
const { data } = supabase.storage.from(AGENDADOR_BUCKET).getPublicUrl(path);
|
||
return data?.publicUrl || null;
|
||
} finally {
|
||
uploadingField.value = null;
|
||
}
|
||
}
|
||
|
||
async function onFileSelected(event, field) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const url = await uploadImagem(file, field);
|
||
if (url) {
|
||
if (field === 'logomarca') cfg.value.logomarca_url = url;
|
||
if (field === 'header') cfg.value.imagem_header_url = url;
|
||
if (field === 'fundo') cfg.value.imagem_fundo_url = url;
|
||
|
||
// Persiste imediatamente no banco sem fechar o accordion
|
||
const uid = ownerId.value;
|
||
const tenantId = await getActiveTenantId(uid);
|
||
await supabase.from('agendador_configuracoes').upsert({ owner_id: uid, tenant_id: tenantId, ...buildPayload('identidade'), updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
|
||
toast.add({ severity: 'success', summary: 'Imagem salva', life: 2000 });
|
||
}
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e.message, life: 4000 });
|
||
}
|
||
}
|
||
|
||
// ── Expand / Collapse all ────────────────────────────────────────
|
||
const CARDS = ['identidade', 'perfil', 'fluxo', 'pagamento', 'triagem', 'textos'];
|
||
function expandAll() {
|
||
expandedCard.value = new Set(CARDS);
|
||
}
|
||
function collapseAll() {
|
||
expandedCard.value = new Set();
|
||
}
|
||
|
||
// ── Defaults ───────────────────────────────────────────────────
|
||
const DEFAULT_CFG = {
|
||
ativo: false,
|
||
link_slug: '',
|
||
imagem_fundo_url: '',
|
||
imagem_header_url: '',
|
||
logomarca_url: '',
|
||
cor_primaria: '#4b6bff',
|
||
nome_exibicao: '',
|
||
endereco: '',
|
||
botao_como_chegar_ativo: true,
|
||
maps_url: '',
|
||
modo_aprovacao: 'aprovacao',
|
||
modalidade: 'presencial',
|
||
tipos_habilitados: ['primeira', 'retorno'],
|
||
duracao_sessao_min: 50,
|
||
antecedencia_minima_horas: 24,
|
||
prazo_resposta_horas: 2,
|
||
reserva_horas: 2,
|
||
pagamento_obrigatorio: false, // mantido para backward compat (derivado de pagamento_modo)
|
||
pagamento_modo: 'sem_pagamento', // 'sem_pagamento' | 'pagar_na_hora' | 'pix_antecipado'
|
||
pagamento_metodos_visiveis: [], // métodos exibidos ao paciente quando pagar_na_hora
|
||
pix_chave: '',
|
||
pix_countdown_minutos: 20,
|
||
triagem_motivo: true,
|
||
triagem_como_conheceu: false,
|
||
verificacao_email: false,
|
||
exigir_aceite_lgpd: true,
|
||
mensagem_boas_vindas: '',
|
||
texto_como_se_preparar: '',
|
||
texto_termos_lgpd: ''
|
||
};
|
||
|
||
const cfg = ref({ ...DEFAULT_CFG });
|
||
|
||
// ── Opções ─────────────────────────────────────────────────────
|
||
const tiposOptions = [
|
||
{ label: 'Primeira Entrevista', value: 'primeira' },
|
||
{ label: 'Retorno', value: 'retorno' },
|
||
{ label: 'Reagendar', value: 'reagendar' }
|
||
];
|
||
|
||
const modalidadeOptions = [
|
||
{ label: 'Presencial', value: 'presencial' },
|
||
{ label: 'Online (vídeo)', value: 'online' },
|
||
{ label: 'Ambos', value: 'ambos' }
|
||
];
|
||
|
||
const modoOptions = [
|
||
{ label: 'Aprovação manual', value: 'aprovacao' },
|
||
{ label: 'Automático', value: 'automatico' }
|
||
];
|
||
|
||
const duracoesOptions = [
|
||
{ label: '30 min', value: 30 },
|
||
{ label: '40 min', value: 40 },
|
||
{ label: '45 min', value: 45 },
|
||
{ label: '50 min', value: 50 },
|
||
{ label: '55 min', value: 55 },
|
||
{ label: '1 hora', value: 60 },
|
||
{ label: '1h 30', value: 90 },
|
||
{ label: '2 horas', value: 120 }
|
||
];
|
||
|
||
const antecedenciaOptions = [
|
||
{ label: 'Sem limite', value: 0 },
|
||
{ label: '1 hora', value: 1 },
|
||
{ label: '2 horas', value: 2 },
|
||
{ label: '6 horas', value: 6 },
|
||
{ label: '12 horas', value: 12 },
|
||
{ label: '24 horas', value: 24 },
|
||
{ label: '48 horas', value: 48 },
|
||
{ label: '72 horas', value: 72 }
|
||
];
|
||
|
||
const reservaOptions = [
|
||
{ label: '1 hora', value: 1 },
|
||
{ label: '2 horas', value: 2 },
|
||
{ label: '4 horas', value: 4 },
|
||
{ label: '6 horas', value: 6 },
|
||
{ label: '12 horas', value: 12 },
|
||
{ label: '24 horas', value: 24 },
|
||
{ label: '48 horas', value: 48 }
|
||
];
|
||
|
||
const pixCountdownOptions = [
|
||
{ label: '5 min', value: 5 },
|
||
{ label: '10 min', value: 10 },
|
||
{ label: '15 min', value: 15 },
|
||
{ label: '20 min', value: 20 },
|
||
{ label: '30 min', value: 30 },
|
||
{ label: '60 min', value: 60 }
|
||
];
|
||
|
||
const prazoRespostaOptions = [
|
||
{ label: '1 hora', value: 1 },
|
||
{ label: '2 horas', value: 2 },
|
||
{ label: '4 horas', value: 4 },
|
||
{ label: '6 horas', value: 6 },
|
||
{ label: '12 horas', value: 12 },
|
||
{ label: '24 horas', value: 24 },
|
||
{ label: '48 horas', value: 48 },
|
||
{ label: '72 horas', value: 72 }
|
||
];
|
||
|
||
// ── Link público ────────────────────────────────────────────────
|
||
const linkPublico = computed(() => {
|
||
if (!cfg.value.link_slug) return '';
|
||
return `${window.location.origin}/agendar/${cfg.value.link_slug}`;
|
||
});
|
||
|
||
const linkCopied = ref(false);
|
||
let _copyTimer = null;
|
||
async function copyLink() {
|
||
try {
|
||
await navigator.clipboard.writeText(linkPublico.value);
|
||
linkCopied.value = true;
|
||
clearTimeout(_copyTimer);
|
||
_copyTimer = setTimeout(() => {
|
||
linkCopied.value = false;
|
||
}, 2000);
|
||
} catch {
|
||
toast.add({ severity: 'warn', summary: 'Copie manualmente', detail: linkPublico.value, life: 5000 });
|
||
}
|
||
}
|
||
|
||
// ── Resumos dos cards ──────────────────────────────────────────
|
||
const resumoIdentidade = computed(() => {
|
||
const parts = [];
|
||
if (cfg.value.nome_exibicao) parts.push(cfg.value.nome_exibicao);
|
||
if (cfg.value.cor_primaria) parts.push(cfg.value.cor_primaria);
|
||
return parts.join(' · ') || 'Não configurado';
|
||
});
|
||
|
||
const resumoPerfil = computed(() => {
|
||
const parts = [];
|
||
if (cfg.value.endereco) parts.push(cfg.value.endereco.slice(0, 40) + (cfg.value.endereco.length > 40 ? '…' : ''));
|
||
if (cfg.value.botao_como_chegar_ativo) parts.push('Como chegar ativo');
|
||
return parts.join(' · ') || 'Não configurado';
|
||
});
|
||
|
||
const resumoFluxo = computed(() => {
|
||
const modo = cfg.value.modo_aprovacao === 'aprovacao' ? 'Aprovação manual' : 'Automático';
|
||
const tipos = (cfg.value.tipos_habilitados || []).length;
|
||
return `${modo} · ${tipos} tipo${tipos !== 1 ? 's' : ''} · ${cfg.value.duracao_sessao_min} min`;
|
||
});
|
||
|
||
const resumoPagamento = computed(() => {
|
||
const modo = cfg.value.pagamento_modo;
|
||
if (modo === 'pix_antecipado') return `Pix obrigatório antes do agendamento · ${cfg.value.pix_countdown_minutos} min para pagar`;
|
||
if (modo === 'pagar_na_hora') {
|
||
const ativos = cfg.value.pagamento_metodos_visiveis || [];
|
||
return ativos.length ? `Pagar na hora · ${ativos.map((m) => METODO_LABEL[m] || m).join(', ')}` : 'Pagar na hora da sessão';
|
||
}
|
||
return 'Sem cobrança antecipada';
|
||
});
|
||
|
||
// ── Payment Settings (lidos de payment_settings para sync) ──────
|
||
const paymentSettings = ref({});
|
||
|
||
const METODO_LABEL = {
|
||
pix: 'Pix',
|
||
deposito: 'Depósito/TED',
|
||
dinheiro: 'Dinheiro',
|
||
cartao: 'Cartão',
|
||
convenio: 'Convênio'
|
||
};
|
||
|
||
// Métodos que o terapeuta tem configurados em payment_settings
|
||
const metodosDisponiveis = computed(() => {
|
||
const ps = paymentSettings.value;
|
||
const todos = [
|
||
{ key: 'pix', label: 'Pix', icon: 'pi-qrcode', ativo: ps.pix_ativo },
|
||
{ key: 'deposito', label: 'Depósito / TED', icon: 'pi-building-columns', ativo: ps.deposito_ativo },
|
||
{ key: 'dinheiro', label: 'Dinheiro', icon: 'pi-wallet', ativo: ps.dinheiro_ativo },
|
||
{ key: 'cartao', label: 'Cartão', icon: 'pi-credit-card', ativo: ps.cartao_ativo },
|
||
{ key: 'convenio', label: 'Convênio', icon: 'pi-heart', ativo: ps.convenio_ativo }
|
||
];
|
||
return todos;
|
||
});
|
||
|
||
const algumMetodoConfigurado = computed(() => metodosDisponiveis.value.some((m) => m.ativo));
|
||
|
||
// chave Pix sincronizada com payment_settings (fallback)
|
||
const pixChaveEfetiva = computed(() => cfg.value.pix_chave || paymentSettings.value.pix_chave || '');
|
||
|
||
async function loadPaymentSettings(uid) {
|
||
try {
|
||
const { data } = await supabase.from('payment_settings').select('pix_ativo, pix_chave, pix_tipo, deposito_ativo, dinheiro_ativo, cartao_ativo, convenio_ativo').eq('owner_id', uid).maybeSingle();
|
||
paymentSettings.value = data || {};
|
||
} catch {
|
||
paymentSettings.value = {};
|
||
}
|
||
}
|
||
|
||
function toggleMetodoVisivel(key) {
|
||
const arr = [...(cfg.value.pagamento_metodos_visiveis || [])];
|
||
const idx = arr.indexOf(key);
|
||
if (idx === -1) arr.push(key);
|
||
else arr.splice(idx, 1);
|
||
cfg.value.pagamento_metodos_visiveis = arr;
|
||
}
|
||
|
||
function isMetodoVisivel(key) {
|
||
return (cfg.value.pagamento_metodos_visiveis || []).includes(key);
|
||
}
|
||
|
||
const modosPagamento = [
|
||
{
|
||
value: 'sem_pagamento',
|
||
label: 'Sem cobrança antecipada',
|
||
desc: 'O horário fica reservado sem exigir pagamento.',
|
||
icon: 'pi-calendar-clock'
|
||
},
|
||
{
|
||
value: 'pagar_na_hora',
|
||
label: 'Pagar na hora da sessão',
|
||
desc: 'O paciente vê as formas de pagamento aceitas e paga no dia.',
|
||
icon: 'pi-wallet'
|
||
},
|
||
{
|
||
value: 'pix_antecipado',
|
||
label: 'Pix antecipado obrigatório',
|
||
desc: 'O paciente paga via Pix antes de o agendamento ser confirmado.',
|
||
icon: 'pi-qrcode'
|
||
}
|
||
];
|
||
|
||
const resumoTriagem = computed(() => {
|
||
const campos = [];
|
||
if (cfg.value.triagem_motivo) campos.push('Motivo');
|
||
if (cfg.value.triagem_como_conheceu) campos.push('Como conheceu');
|
||
if (cfg.value.verificacao_email) campos.push('Verificação e-mail');
|
||
if (cfg.value.exigir_aceite_lgpd) campos.push('LGPD');
|
||
return campos.join(' · ') || 'Sem triagem extra';
|
||
});
|
||
|
||
const resumoTextos = computed(() => {
|
||
const bv = cfg.value.mensagem_boas_vindas?.trim();
|
||
const cp = cfg.value.texto_como_se_preparar?.trim();
|
||
if (bv || cp) return 'Textos configurados';
|
||
return 'Nenhum texto configurado';
|
||
});
|
||
|
||
// ── Auth / Tenant ──────────────────────────────────────────────
|
||
async function getOwnerId() {
|
||
const { data, error } = await supabase.auth.getUser();
|
||
if (error) throw error;
|
||
return data?.user?.id;
|
||
}
|
||
|
||
async function getActiveTenantId(uid) {
|
||
const fromStore = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||
if (fromStore) return fromStore;
|
||
const { data } = await supabase.from('tenant_members').select('tenant_id').eq('user_id', uid).eq('status', 'active').limit(1).maybeSingle();
|
||
return data?.tenant_id || null;
|
||
}
|
||
|
||
// ── Load ───────────────────────────────────────────────────────
|
||
async function load() {
|
||
loading.value = true;
|
||
try {
|
||
const uid = await getOwnerId();
|
||
ownerId.value = uid;
|
||
|
||
const [{ data, error }] = await Promise.all([supabase.from('agendador_configuracoes').select('*').eq('owner_id', uid).maybeSingle(), loadPaymentSettings(uid)]);
|
||
|
||
if (error) throw error;
|
||
|
||
if (data) {
|
||
const loaded = {
|
||
...DEFAULT_CFG,
|
||
...data,
|
||
tipos_habilitados: Array.isArray(data.tipos_habilitados) ? data.tipos_habilitados : DEFAULT_CFG.tipos_habilitados,
|
||
pagamento_metodos_visiveis: Array.isArray(data.pagamento_metodos_visiveis) ? data.pagamento_metodos_visiveis : []
|
||
};
|
||
// backward compat: se coluna pagamento_modo não existe ainda no banco, deriva de pagamento_obrigatorio
|
||
if (!data.pagamento_modo) {
|
||
loaded.pagamento_modo = data.pagamento_obrigatorio ? 'pix_antecipado' : 'sem_pagamento';
|
||
}
|
||
cfg.value = loaded;
|
||
} else {
|
||
// Seed com nome do tenant como sugestão
|
||
cfg.value = { ...DEFAULT_CFG };
|
||
if (tenantStore.tenant?.name) cfg.value.nome_exibicao = tenantStore.tenant.name;
|
||
}
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// ── Toggle ativo ───────────────────────────────────────────────
|
||
async function toggleAtivo() {
|
||
const uid = ownerId.value;
|
||
if (!uid) return;
|
||
|
||
const novoAtivo = !cfg.value.ativo;
|
||
cfg.value.ativo = novoAtivo;
|
||
|
||
try {
|
||
const tenantId = await getActiveTenantId(uid);
|
||
|
||
await supabase.from('agendador_configuracoes').upsert({ owner_id: uid, tenant_id: tenantId, ativo: novoAtivo, updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
|
||
|
||
toast.add({
|
||
severity: novoAtivo ? 'success' : 'info',
|
||
summary: novoAtivo ? 'Agendador ativado' : 'Agendador desativado',
|
||
life: 3000
|
||
});
|
||
if (novoAtivo) await load(); // recarrega para obter o slug gerado pelo trigger
|
||
} catch (e) {
|
||
cfg.value.ativo = !novoAtivo;
|
||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||
}
|
||
}
|
||
|
||
// ── Save card ──────────────────────────────────────────────────
|
||
async function saveCard(cardKey) {
|
||
savingCard.value = cardKey;
|
||
try {
|
||
const uid = ownerId.value;
|
||
const tenantId = await getActiveTenantId(uid);
|
||
const payload = buildPayload(cardKey);
|
||
|
||
await supabase.from('agendador_configuracoes').upsert({ owner_id: uid, tenant_id: tenantId, ...payload, updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
|
||
|
||
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 });
|
||
expandedCard.value = new Set();
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||
} finally {
|
||
savingCard.value = null;
|
||
}
|
||
}
|
||
|
||
function buildPayload(cardKey) {
|
||
const c = cfg.value;
|
||
if (cardKey === 'identidade') {
|
||
return {
|
||
imagem_fundo_url: c.imagem_fundo_url,
|
||
imagem_header_url: c.imagem_header_url,
|
||
logomarca_url: c.logomarca_url,
|
||
cor_primaria: c.cor_primaria
|
||
};
|
||
}
|
||
if (cardKey === 'perfil') {
|
||
return {
|
||
nome_exibicao: c.nome_exibicao,
|
||
endereco: c.endereco,
|
||
botao_como_chegar_ativo: c.botao_como_chegar_ativo,
|
||
maps_url: c.maps_url
|
||
};
|
||
}
|
||
if (cardKey === 'fluxo') {
|
||
return {
|
||
modo_aprovacao: c.modo_aprovacao,
|
||
modalidade: c.modalidade,
|
||
tipos_habilitados: c.tipos_habilitados,
|
||
duracao_sessao_min: c.duracao_sessao_min,
|
||
antecedencia_minima_horas: c.antecedencia_minima_horas,
|
||
prazo_resposta_horas: c.prazo_resposta_horas
|
||
};
|
||
}
|
||
if (cardKey === 'pagamento') {
|
||
const modo = c.pagamento_modo || 'sem_pagamento';
|
||
return {
|
||
pagamento_modo: modo,
|
||
pagamento_obrigatorio: modo === 'pix_antecipado', // backward compat
|
||
pagamento_metodos_visiveis: c.pagamento_metodos_visiveis || [],
|
||
pix_chave: c.pix_chave?.trim() ?? '',
|
||
pix_countdown_minutos: c.pix_countdown_minutos,
|
||
reserva_horas: c.reserva_horas
|
||
};
|
||
}
|
||
if (cardKey === 'triagem') {
|
||
return {
|
||
triagem_motivo: c.triagem_motivo,
|
||
triagem_como_conheceu: c.triagem_como_conheceu,
|
||
verificacao_email: c.verificacao_email,
|
||
exigir_aceite_lgpd: c.exigir_aceite_lgpd
|
||
};
|
||
}
|
||
if (cardKey === 'textos') {
|
||
return {
|
||
mensagem_boas_vindas: c.mensagem_boas_vindas,
|
||
texto_como_se_preparar: c.texto_como_se_preparar,
|
||
texto_termos_lgpd: c.texto_termos_lgpd
|
||
};
|
||
}
|
||
if (cardKey === 'slug') {
|
||
return {
|
||
link_slug:
|
||
c.link_slug
|
||
?.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9-]/g, '-') || null
|
||
};
|
||
}
|
||
return {};
|
||
}
|
||
|
||
function toggleCard(key) {
|
||
const s = new Set(expandedCard.value);
|
||
if (s.has(key)) s.delete(key);
|
||
else s.add(key);
|
||
expandedCard.value = s;
|
||
}
|
||
|
||
onMounted(load);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col gap-4">
|
||
<!-- ── SKELETON ──────────────────────────────────────────────── -->
|
||
<template v-if="loading">
|
||
<div class="flex flex-col xl:flex-row gap-4">
|
||
<!-- Coluna esquerda: cards skeleton -->
|
||
<div class="flex flex-col gap-4 min-w-0 xl:flex-1">
|
||
<!-- Subheader skeleton -->
|
||
<div class="cfg-subheader">
|
||
<AppLoadingPhrases class="w-full" />
|
||
</div>
|
||
|
||
<!-- Card status skeleton -->
|
||
<div class="agd-card flex flex-col gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<Skeleton width="44px" height="44px" border-radius="6px" />
|
||
<div class="flex flex-col gap-2 flex-1">
|
||
<Skeleton width="180px" height="16px" />
|
||
<Skeleton width="120px" height="12px" />
|
||
</div>
|
||
<Skeleton width="48px" height="24px" border-radius="12px" />
|
||
</div>
|
||
<Skeleton height="1px" />
|
||
<Skeleton width="260px" height="36px" border-radius="6px" />
|
||
</div>
|
||
|
||
<!-- Section skeletons -->
|
||
<div v-for="n in 5" :key="n" class="agd-card flex flex-col gap-3 p-0 overflow-hidden">
|
||
<div class="flex items-center gap-3 px-4 py-3 bg-surface-50 dark:bg-surface-800 border-b border-surface-border">
|
||
<Skeleton width="32px" height="32px" border-radius="6px" />
|
||
<Skeleton :width="n % 2 === 0 ? '160px' : '140px'" height="14px" />
|
||
</div>
|
||
<div class="px-4 pb-4 flex flex-col gap-2">
|
||
<Skeleton :width="n % 3 === 0 ? '90%' : '80%'" height="11px" />
|
||
<Skeleton width="60%" height="11px" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Coluna direita: phone frame skeleton -->
|
||
<div class="xl:w-[280px] xl:self-start xl:sticky xl:top-4">
|
||
<Skeleton width="260px" height="500px" border-radius="2.5rem" class="mx-auto" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<!-- ── 2 COLUNAS: seções (esq) + preview (dir) ───────────── -->
|
||
<div class="flex flex-col xl:flex-row gap-4 items-start">
|
||
<!-- Coluna esquerda: todos os cards -->
|
||
<div class="flex flex-col gap-4 min-w-0 xl:flex-1">
|
||
<!-- ── CARD: STATUS / ATIVAR ──────────────────────────────── -->
|
||
<div class="agd-card">
|
||
<div class="flex flex-col gap-4">
|
||
<!-- Cabeçalho PRO -->
|
||
<div class="flex items-start justify-between gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="grid place-items-center w-11 h-11 rounded-[6px] shrink-0" :class="cfg.ativo ? 'bg-green-100 dark:bg-green-900/30 text-green-600' : 'bg-surface-100 text-surface-400'">
|
||
<i class="pi pi-calendar-clock text-xl" />
|
||
</div>
|
||
<div>
|
||
<div class="font-bold text-lg leading-none">Agendador Online</div>
|
||
<div class="text-sm text-surface-500 mt-1">Funcionalidade <Tag value="PRO" severity="contrast" class="text-xs ml-1" /></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3 shrink-0">
|
||
<template v-if="hasAgendador">
|
||
<span class="text-sm font-medium" :class="cfg.ativo ? 'text-green-600' : 'text-surface-400'">
|
||
{{ cfg.ativo ? 'Ativo' : 'Inativo' }}
|
||
</span>
|
||
<ToggleSwitch :modelValue="cfg.ativo" @update:modelValue="toggleAtivo" />
|
||
</template>
|
||
<template v-else>
|
||
<Tag value="Plano não inclui" severity="warn" class="text-xs" />
|
||
<ToggleSwitch :modelValue="false" disabled v-tooltip.left="'Seu plano não inclui o Agendador Online'" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider class="my-0" />
|
||
|
||
<!-- Link público -->
|
||
<div v-if="cfg.ativo">
|
||
<div class="text-sm font-semibold text-surface-600 mb-2">Link público do agendador</div>
|
||
|
||
<!-- Gerando slug (aguarda trigger do banco) -->
|
||
<div v-if="!cfg.link_slug" class="flex items-center gap-2 text-sm text-surface-400"><i class="pi pi-spin pi-spinner text-xs" /> Gerando link...</div>
|
||
|
||
<!-- Link disponível -->
|
||
<template v-else>
|
||
<InputGroup>
|
||
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
||
<InputText readonly :value="linkPublico" class="font-mono text-xs" />
|
||
<Button :icon="linkCopied ? 'pi pi-check' : 'pi pi-copy'" :severity="linkCopied ? 'success' : 'secondary'" title="Copiar link" @click="copyLink()" />
|
||
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="() => window.open(linkPublico, '_blank', 'noopener')" />
|
||
</InputGroup>
|
||
<div class="text-xs text-surface-400 mt-2">Este link é permanente e nunca muda. O link personalizado (se ativo) é um apelido — este continua funcionando mesmo se o apelido for removido.</div>
|
||
|
||
<!-- Link personalizado bloqueado -->
|
||
<div v-if="!hasLinkPersonalizado" class="mt-3 flex items-center gap-3 p-3 rounded-[6px] border border-dashed border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800/50">
|
||
<div class="grid place-items-center w-9 h-9 rounded-[6px] bg-amber-100 dark:bg-amber-900/30 text-amber-500 shrink-0">
|
||
<i class="pi pi-lock text-base" />
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-sm font-semibold leading-none">Link Personalizado</div>
|
||
<div class="text-xs text-surface-400 mt-1">
|
||
Escolha sua própria URL — <span class="font-mono">/agendar/<b>dra-ana-silva</b></span
|
||
>. Disponível em planos superiores.
|
||
</div>
|
||
</div>
|
||
<Tag value="Upgrade" severity="warn" class="shrink-0 text-xs" />
|
||
</div>
|
||
|
||
<!-- Input para slug personalizado -->
|
||
<div v-else class="mt-3 flex items-center gap-2">
|
||
<InputGroup>
|
||
<InputGroupAddon class="text-xs text-surface-400 font-mono">/agendar/</InputGroupAddon>
|
||
<InputText v-model="cfg.link_slug" placeholder="dra-ana-silva" class="font-mono" />
|
||
<Button label="Salvar" icon="pi pi-check" @click="saveCard('slug')" />
|
||
</InputGroup>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div v-else class="text-sm text-surface-500 leading-relaxed">Ative o agendador para que seus pacientes possam solicitar horários online. Você controla quem pode agendar e quais horários ficam disponíveis.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEÇÃO: IDENTIDADE VISUAL ─────────────────────────── -->
|
||
<div class="agd-section">
|
||
<div class="agd-section__head">
|
||
<div class="agd-section__icon bg-purple-100 dark:bg-purple-900/30 text-purple-600">
|
||
<i class="pi pi-palette" />
|
||
</div>
|
||
<span class="agd-section__title">Identidade Visual</span>
|
||
</div>
|
||
<div class="agd-section__body">
|
||
<!-- Aparência -->
|
||
<div class="agd-group-title">Aparência</div>
|
||
<div class="px-1 grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Nome de exibição</label>
|
||
<InputText v-model="cfg.nome_exibicao" placeholder="Ex: Dra. Ana Silva — Psicóloga" class="w-full" />
|
||
<span class="cfg-hint">Como aparece no topo do agendador.</span>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Cor principal</label>
|
||
<div class="flex items-center gap-3">
|
||
<ColorPicker v-model="cfg.cor_primaria" format="hex" />
|
||
<InputText v-model="cfg.cor_primaria" placeholder="#4b6bff" class="w-32 font-mono" maxlength="7" />
|
||
<div class="w-10 h-10 rounded-[6px] border border-surface-200 shrink-0" :style="{ background: cfg.cor_primaria }" />
|
||
</div>
|
||
<span class="cfg-hint">Botões e destaques do agendador.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Imagens -->
|
||
<Divider class="my-0" />
|
||
<div class="agd-group-title">Imagens</div>
|
||
<div class="px-1 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<!-- Logomarca -->
|
||
<div class="cfg-card">
|
||
<div class="cfg-card__head">
|
||
<i class="pi pi-user-circle text-xs opacity-50" />
|
||
<span>Logomarca</span>
|
||
</div>
|
||
<div class="p-3 flex flex-col gap-2">
|
||
<div class="cfg-hint opacity-60">Avatar circular — 300×300 px</div>
|
||
<div v-if="cfg.logomarca_url" class="flex justify-center">
|
||
<img :src="cfg.logomarca_url" alt="Logomarca" class="w-16 h-16 rounded-full object-cover border-2 border-surface-200" />
|
||
</div>
|
||
<div class="agd-upload-zone flex-col text-center" @click="fileInputLogo.click()">
|
||
<i class="pi pi-upload text-surface-400" />
|
||
<span>{{ uploadingField === 'logomarca' ? 'Enviando...' : 'Clique para enviar' }}</span>
|
||
<input ref="fileInputLogo" type="file" accept="image/*" class="hidden" @change="(e) => onFileSelected(e, 'logomarca')" />
|
||
</div>
|
||
<InputText v-model="cfg.logomarca_url" placeholder="ou cole uma URL..." class="w-full" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Imagem do header -->
|
||
<div class="cfg-card">
|
||
<div class="cfg-card__head">
|
||
<i class="pi pi-image text-xs opacity-50" />
|
||
<span>Header</span>
|
||
</div>
|
||
<div class="p-3 flex flex-col gap-2">
|
||
<div class="cfg-hint opacity-60">Faixa superior — 1400×300 px</div>
|
||
<div v-if="cfg.imagem_header_url" class="rounded-[6px] overflow-hidden h-16 w-full">
|
||
<img :src="cfg.imagem_header_url" alt="Header" class="w-full h-full object-cover" />
|
||
</div>
|
||
<div class="agd-upload-zone flex-col text-center" @click="fileInputHeader.click()">
|
||
<i class="pi pi-upload text-surface-400" />
|
||
<span>{{ uploadingField === 'header' ? 'Enviando...' : 'Clique para enviar' }}</span>
|
||
<input ref="fileInputHeader" type="file" accept="image/*" class="hidden" @change="(e) => onFileSelected(e, 'header')" />
|
||
</div>
|
||
<InputText v-model="cfg.imagem_header_url" placeholder="ou cole uma URL..." class="w-full" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Imagem de fundo -->
|
||
<div class="cfg-card">
|
||
<div class="cfg-card__head">
|
||
<i class="pi pi-stop text-xs opacity-50" />
|
||
<span>Fundo</span>
|
||
</div>
|
||
<div class="p-3 flex flex-col gap-2">
|
||
<div class="cfg-hint opacity-60">Fundo da página — 1920×1080 px</div>
|
||
<div v-if="cfg.imagem_fundo_url" class="rounded-[6px] overflow-hidden h-16 w-full">
|
||
<img :src="cfg.imagem_fundo_url" alt="Fundo" class="w-full h-full object-cover" />
|
||
</div>
|
||
<div class="agd-upload-zone flex-col text-center" @click="fileInputFundo.click()">
|
||
<i class="pi pi-upload text-surface-400" />
|
||
<span>{{ uploadingField === 'fundo' ? 'Enviando...' : 'Clique para enviar' }}</span>
|
||
<input ref="fileInputFundo" type="file" accept="image/*" class="hidden" @change="(e) => onFileSelected(e, 'fundo')" />
|
||
</div>
|
||
<InputText v-model="cfg.imagem_fundo_url" placeholder="ou cole uma URL..." class="w-full" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<Button label="Salvar identidade visual" icon="pi pi-check" :loading="savingCard === 'identidade'" @click="saveCard('identidade')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEÇÃO: PERFIL PÚBLICO ──────────────────────────────── -->
|
||
<div class="agd-section">
|
||
<div class="agd-section__head">
|
||
<div class="agd-section__icon bg-blue-100 dark:bg-blue-900/30 text-blue-600">
|
||
<i class="pi pi-map-marker" />
|
||
</div>
|
||
<span class="agd-section__title">Perfil Público</span>
|
||
</div>
|
||
<div class="agd-section__body">
|
||
<!-- Localização -->
|
||
<div class="agd-group-title">Localização</div>
|
||
<div class="px-1 flex flex-col gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Endereço</label>
|
||
<InputText v-model="cfg.endereco" placeholder="Rua das Flores, 123, Centro — São Paulo, SP" class="w-full" />
|
||
</div>
|
||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||
<div>
|
||
<div class="font-semibold">Botão "Como chegar"</div>
|
||
<div class="text-surface-400 mt-0.5">Exibe um botão que abre o mapa para o paciente.</div>
|
||
</div>
|
||
<ToggleSwitch v-model="cfg.botao_como_chegar_ativo" />
|
||
</div>
|
||
<div v-if="cfg.botao_como_chegar_ativo" class="flex flex-col gap-1">
|
||
<label class="cfg-label">URL do Google Maps <span class="font-normal opacity-60">(opcional)</span></label>
|
||
<InputText v-model="cfg.maps_url" placeholder="https://maps.google.com/..." class="w-full" />
|
||
<span class="cfg-hint">Se vazio, abre uma busca pelo endereço acima.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<Button label="Salvar perfil" icon="pi pi-check" :loading="savingCard === 'perfil'" @click="saveCard('perfil')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEÇÃO: FLUXO DE AGENDAMENTO ───────────────────────── -->
|
||
<div class="agd-section">
|
||
<div class="agd-section__head">
|
||
<div class="agd-section__icon bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600">
|
||
<i class="pi pi-sitemap" />
|
||
</div>
|
||
<span class="agd-section__title">Fluxo de Agendamento</span>
|
||
</div>
|
||
<div class="agd-section__body">
|
||
<!-- Aprovação -->
|
||
<div class="agd-group-title">Aprovação</div>
|
||
<div class="px-1 flex flex-col gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Modo de aprovação</label>
|
||
<div class="flex flex-col gap-2">
|
||
<div
|
||
v-for="opt in modoOptions"
|
||
:key="opt.value"
|
||
class="flex items-center gap-3 p-3 rounded-[6px] border cursor-pointer transition"
|
||
:class="cfg.modo_aprovacao === opt.value ? 'border-primary bg-primary/5 dark:bg-primary/10' : 'border-surface-200 dark:border-surface-700 hover:border-surface-300'"
|
||
@click="cfg.modo_aprovacao = opt.value"
|
||
>
|
||
<RadioButton :modelValue="cfg.modo_aprovacao" :value="opt.value" />
|
||
<div>
|
||
<div class="font-medium">{{ opt.label }}</div>
|
||
<div class="text-surface-400">
|
||
<template v-if="opt.value === 'aprovacao'">Você analisa cada solicitação e autoriza manualmente. O horário fica reservado até a resposta.</template>
|
||
<template v-else>Agendamentos confirmados automaticamente. O evento é criado na agenda sem revisão.</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="cfg.modo_aprovacao === 'aprovacao'" class="flex flex-col gap-1">
|
||
<label class="cfg-label">Prazo para responder a solicitação</label>
|
||
<Select v-model="cfg.prazo_resposta_horas" :options="prazoRespostaOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||
<span class="cfg-hint">Se não responder no prazo, o paciente é notificado e o horário é liberado.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider class="my-0" />
|
||
|
||
<!-- Atendimento -->
|
||
<div class="agd-group-title">Atendimento</div>
|
||
<div class="px-1 flex flex-col gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Modalidade</label>
|
||
<SelectButton v-model="cfg.modalidade" :options="modalidadeOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Tipos de agendamento disponíveis</label>
|
||
<div class="flex flex-wrap gap-2">
|
||
<div
|
||
v-for="opt in tiposOptions"
|
||
:key="opt.value"
|
||
class="flex items-center gap-2 px-3 py-2 rounded-full border cursor-pointer transition select-none font-medium"
|
||
:class="cfg.tipos_habilitados?.includes(opt.value) ? 'border-primary bg-primary text-white' : 'border-surface-200 dark:border-surface-700 hover:border-primary/50'"
|
||
@click="
|
||
() => {
|
||
const list = [...(cfg.tipos_habilitados || [])];
|
||
const idx = list.indexOf(opt.value);
|
||
if (idx >= 0) list.splice(idx, 1);
|
||
else list.push(opt.value);
|
||
cfg.tipos_habilitados = list;
|
||
}
|
||
"
|
||
>
|
||
<i class="pi pi-check" v-if="cfg.tipos_habilitados?.includes(opt.value)" />
|
||
{{ opt.label }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider class="my-0" />
|
||
|
||
<!-- Horários -->
|
||
<div class="agd-group-title">Horários</div>
|
||
<div class="px-1 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Duração da sessão</label>
|
||
<Select v-model="cfg.duracao_sessao_min" :options="duracoesOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||
<span class="cfg-hint">Usado para bloquear o horário correto na agenda após aprovação.</span>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Antecedência mínima para agendar</label>
|
||
<Select v-model="cfg.antecedencia_minima_horas" :options="antecedenciaOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||
<span class="cfg-hint">Pacientes não conseguem agendar com menos de X horas de antecedência.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<Button label="Salvar fluxo" icon="pi pi-check" :loading="savingCard === 'fluxo'" @click="saveCard('fluxo')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEÇÃO: PAGAMENTO ───────────────────────────────────── -->
|
||
<div class="agd-section">
|
||
<div class="agd-section__head">
|
||
<div class="agd-section__icon bg-green-100 dark:bg-green-900/30 text-green-600">
|
||
<i class="pi pi-credit-card" />
|
||
</div>
|
||
<span class="agd-section__title">Pagamento</span>
|
||
</div>
|
||
<div class="agd-section__body">
|
||
<!-- Como o paciente vai pagar -->
|
||
<div class="agd-group-title">Como o paciente vai pagar?</div>
|
||
<div class="px-1 flex flex-col gap-2">
|
||
<button
|
||
v-for="modo in modosPagamento"
|
||
:key="modo.value"
|
||
type="button"
|
||
class="flex items-center gap-3 p-3 rounded-[6px] border text-left transition"
|
||
:class="cfg.pagamento_modo === modo.value ? 'border-primary bg-primary/5 ring-1 ring-primary/30' : 'border-surface-border bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'"
|
||
@click="cfg.pagamento_modo = modo.value"
|
||
>
|
||
<div class="grid place-items-center w-9 h-9 rounded-[6px] shrink-0" :class="cfg.pagamento_modo === modo.value ? 'bg-primary/15 text-primary' : 'bg-surface-200 dark:bg-surface-700 text-surface-400'">
|
||
<i :class="['pi', modo.icon]" />
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="font-semibold">{{ modo.label }}</div>
|
||
<div class="text-surface-400">{{ modo.desc }}</div>
|
||
</div>
|
||
<i v-if="cfg.pagamento_modo === modo.value" class="pi pi-check-circle text-primary ml-auto shrink-0" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Pagar na hora (condicional) -->
|
||
<template v-if="cfg.pagamento_modo === 'pagar_na_hora'">
|
||
<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="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="pagamentoPath" class="underline font-medium ml-1">Configurar agora</RouterLink>
|
||
</div>
|
||
<div class="flex flex-col gap-2">
|
||
<label
|
||
v-for="m in metodosDisponiveis"
|
||
:key="m.key"
|
||
class="flex items-center gap-3 p-3 rounded-[6px] border cursor-pointer transition select-none"
|
||
:class="[
|
||
!m.ativo
|
||
? 'opacity-40 cursor-not-allowed border-surface-border bg-surface-50 dark:bg-surface-800'
|
||
: isMetodoVisivel(m.key)
|
||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||
: 'border-surface-border bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'
|
||
]"
|
||
>
|
||
<Checkbox :modelValue="isMetodoVisivel(m.key)" :disabled="!m.ativo" binary @change="m.ativo && toggleMetodoVisivel(m.key)" />
|
||
<i :class="['pi', m.icon]" />
|
||
<span class="font-medium flex-1">{{ m.label }}</span>
|
||
<Tag v-if="!m.ativo" value="Não configurado" severity="secondary" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Pix antecipado (condicional) -->
|
||
<template v-if="cfg.pagamento_modo === 'pix_antecipado'">
|
||
<Divider class="my-0" />
|
||
<div class="agd-group-title">Configurações do Pix</div>
|
||
<div class="px-1 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Chave Pix</label>
|
||
<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="pagamentoPath" class="underline">Formas de Pagamento</RouterLink>
|
||
({{ paymentSettings.pix_chave }}).
|
||
</span>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Tempo para realizar o pagamento</label>
|
||
<Select v-model="cfg.pix_countdown_minutos" :options="pixCountdownOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||
<span class="cfg-hint">Se o paciente não pagar no prazo, o horário é liberado automaticamente.</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<Divider class="my-0" />
|
||
|
||
<!-- Reserva do horário -->
|
||
<div class="agd-group-title">Reserva do horário</div>
|
||
<div class="px-1 flex flex-col gap-1">
|
||
<label class="cfg-label">Tempo de reserva</label>
|
||
<Select v-model="cfg.reserva_horas" :options="reservaOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||
<span class="cfg-hint">
|
||
<template v-if="cfg.pagamento_modo === 'pix_antecipado'"> Tempo em que o horário fica bloqueado aguardando o pagamento Pix. </template>
|
||
<template v-else> Enquanto a solicitação está pendente, o horário fica bloqueado por este período. Após expirar, volta a ficar disponível. </template>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<Button label="Salvar pagamento" icon="pi pi-check" :loading="savingCard === 'pagamento'" @click="saveCard('pagamento')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEÇÃO: TRIAGEM & CONFORMIDADE ─────────────────────── -->
|
||
<div class="agd-section">
|
||
<div class="agd-section__head">
|
||
<div class="agd-section__icon bg-orange-100 dark:bg-orange-900/30 text-orange-600">
|
||
<i class="pi pi-shield" />
|
||
</div>
|
||
<span class="agd-section__title">Triagem & Conformidade</span>
|
||
</div>
|
||
<div class="agd-section__body">
|
||
<!-- Campos extras no formulário -->
|
||
<div class="agd-group-title">Campos extras no formulário</div>
|
||
<div class="px-1 flex flex-col gap-2">
|
||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||
<div>
|
||
<div class="font-semibold">Motivo da consulta</div>
|
||
<div class="text-surface-400 mt-0.5">Campo de texto livre opcional para o paciente informar o motivo.</div>
|
||
</div>
|
||
<ToggleSwitch v-model="cfg.triagem_motivo" />
|
||
</div>
|
||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||
<div>
|
||
<div class="font-semibold">Como nos conheceu?</div>
|
||
<div class="text-surface-400 mt-0.5">Pergunta de origem (indicação, redes sociais, busca…).</div>
|
||
</div>
|
||
<ToggleSwitch v-model="cfg.triagem_como_conheceu" />
|
||
</div>
|
||
</div>
|
||
|
||
<Divider class="my-0" />
|
||
|
||
<!-- Segurança & LGPD -->
|
||
<div class="agd-group-title">Segurança & LGPD</div>
|
||
<div class="px-1 flex flex-col gap-2">
|
||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||
<div>
|
||
<div class="font-semibold">Verificação de e-mail</div>
|
||
<div class="text-surface-400 mt-0.5">Paciente confirma o e-mail antes de concluir o agendamento.</div>
|
||
</div>
|
||
<ToggleSwitch v-model="cfg.verificacao_email" />
|
||
</div>
|
||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||
<div>
|
||
<div class="font-semibold">Aceite obrigatório de termos (LGPD)</div>
|
||
<div class="text-surface-400 mt-0.5">Exige que o paciente marque o aceite da política de privacidade antes de finalizar.</div>
|
||
</div>
|
||
<ToggleSwitch v-model="cfg.exigir_aceite_lgpd" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<Button label="Salvar triagem" icon="pi pi-check" :loading="savingCard === 'triagem'" @click="saveCard('triagem')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEÇÃO: TEXTOS DA JORNADA ──────────────────────────── -->
|
||
<div class="agd-section">
|
||
<div class="agd-section__head">
|
||
<div class="agd-section__icon bg-pink-100 dark:bg-pink-900/30 text-pink-600">
|
||
<i class="pi pi-file-edit" />
|
||
</div>
|
||
<span class="agd-section__title">Textos da Jornada</span>
|
||
</div>
|
||
<div class="agd-section__body">
|
||
<!-- Mensagens ao paciente -->
|
||
<div class="agd-group-title">Mensagens ao paciente</div>
|
||
<div class="px-1 flex flex-col gap-5">
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Mensagem de boas-vindas</label>
|
||
<span class="cfg-hint">Aparece logo após o paciente informar o nome.</span>
|
||
<Editor v-model="cfg.mensagem_boas_vindas" style="margin-top: 0.25rem" editorStyle="min-height: 80px; font-size: 1rem;">
|
||
<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>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label class="cfg-label">Como se preparar para a sessão</label>
|
||
<span class="cfg-hint">Exibido na tela de confirmação após o agendamento. Dicas, instruções de local, etc.</span>
|
||
<Editor v-model="cfg.texto_como_se_preparar" style="margin-top: 0.25rem" editorStyle="min-height: 120px; font-size: 1rem;">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Termos (condicional) -->
|
||
<template v-if="cfg.exigir_aceite_lgpd">
|
||
<Divider class="my-0" />
|
||
<div class="agd-group-title">Termos de uso / Política de privacidade</div>
|
||
<div class="px-1 flex flex-col gap-1">
|
||
<span class="cfg-hint">Aparece no checkbox de aceite obrigatório. Pode incluir link para documento completo.</span>
|
||
<Editor v-model="cfg.texto_termos_lgpd" style="margin-top: 0.25rem" editorStyle="min-height: 120px; font-size: 1rem;">
|
||
<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>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="flex justify-end">
|
||
<Button label="Salvar textos" icon="pi pi-check" :loading="savingCard === 'textos'" @click="saveCard('textos')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- /textos -->
|
||
|
||
<!-- LoadedPhraseBlock abaixo do último card -->
|
||
<LoadedPhraseBlock />
|
||
</div>
|
||
<!-- /coluna esquerda -->
|
||
|
||
<!-- Coluna direita: preview do agendador -->
|
||
<div class="xl:w-[280px] xl:self-start xl:sticky xl:top-4">
|
||
<AgendadorPreview :cfg="cfg" />
|
||
</div>
|
||
</div>
|
||
<!-- /2 colunas -->
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ── Upload zone ──────────────────────────────────── */
|
||
.agd-upload-zone {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.5rem;
|
||
padding: 0.75rem 1rem;
|
||
border: 1.5px dashed var(--surface-border);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
background 0.15s;
|
||
background: var(--surface-ground);
|
||
}
|
||
.agd-upload-zone:hover {
|
||
border-color: var(--primary-color, #6366f1);
|
||
background: color-mix(in srgb, var(--primary-color, #6366f1) 5%, transparent);
|
||
}
|
||
|
||
/* ── Card status (sem accordion) ─────────────────── */
|
||
.agd-card {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 6px;
|
||
background: var(--surface-card);
|
||
padding: 1rem;
|
||
}
|
||
|
||
/* ── Seções sempre abertas (estilo Minha Empresa) ──── */
|
||
.agd-section {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 8px;
|
||
background: var(--surface-card);
|
||
overflow: hidden;
|
||
}
|
||
.agd-section__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.65rem 1rem;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
background: var(--surface-ground);
|
||
}
|
||
.agd-section__icon {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border-radius: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.agd-section__title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--text-color);
|
||
}
|
||
.agd-section__body {
|
||
padding: 0.75rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
/* ── Sub-seção cards (mesmo padrão que MinhaEmpresa) ─── */
|
||
.cfg-card {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 8px;
|
||
background: var(--surface-card);
|
||
overflow: hidden;
|
||
}
|
||
.cfg-card__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.55rem 1rem;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
background: var(--surface-ground);
|
||
font-size: 0.8rem;
|
||
font-weight: 700;
|
||
}
|
||
.cfg-label {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
.cfg-hint {
|
||
font-size: 1rem;
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.8;
|
||
}
|
||
.agd-group-title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--text-color-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
opacity: 0.65;
|
||
}
|
||
</style>
|