c23d0a574f
DB - migration 20260519000001: create_financial_record_for_session passa a ignorar status='cancelled' na idempotência (era bug — cancelar e tentar regerar travava silencioso retornando o cancelado) Cenário 5 (convênio) — fixes pra save + visualização - Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id e usa insurance_value. payment_method forçado 'convenio' (era 'asaas') - Popover: ev.price era null em convênio → normalize expõe insurance_value e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente - /financeiro: branch novo pra payment_method='convenio' → pill violeta com pi-id-card (antes ficava sem indicador, igual particular) Cenário 6 (recorrente sem pacote, Maria Magali) — materialização - chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem badge $). Agora materializa a 1ª no fluxo de criação recorrente - Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository dropa esse campo. Corrigido pra 'patient_id' (English DB column) Atalho "Gerar fatura" no popover - Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant ='none' + sessão materializada) - Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick (chama gerarCobrancaManual, fecha popover pra impedir double-click) - Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel agora filtram status='cancelled' (resolve badge $ residual + botão sumido) Header do popover: info de pacote/série - "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo (computed seriesLabel lê do _raw da rule) Título do dialog "Sessão do Pacote · Sessão" - Quando commitment name é "Sessão" (default), drop pra evitar duplicação - Outros nomes (Avaliação, etc) permanecem com forma completa Excluir série inteira (popover) - Novo botão "Excluir série" no popover quando evento pertence a recorrência - Hard delete: financial_records pendentes → agenda_eventos materializados → recurrence_rules (CASCADE leva exceptions + rule_services) - Bloqueia se algum record tem status='paid' (estornar primeiro) cancel_session some da agenda - useRecurrence.expandRules agora pula occurrence com exception type= 'cancel_session' (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha. Honra a promessa do diálogo) - patient_missed / therapist_canceled / holiday_block permanecem visíveis como histórico UX outros - "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava — empty state mandava clicar em botão inexistente) - InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada estava selecionado antes - Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia opcional"; gratuito = "sem cobrança". Particular sem hint - recurrence_exceptions cancel agora usa upsert com onConflict (idempotente, não quebra com unique violation em re-cancel) - goToConveniosConfig removida (dead code após quick-create inline) CSS - .aed-row-50 perdeu margin-bottom (user request) - .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3931 lines
171 KiB
Vue
3931 lines
171 KiB
Vue
<script setup>
|
||
/*
|
||
* MelissaLayout — Layout "lockscreen" (Direção B do redesign)
|
||
* --------------------------------------------------
|
||
* Tela-resumo (zen, info passiva) com botão único no canto inferior
|
||
* esquerdo que sobe um workspace por cima (com blur+dim no fundo).
|
||
*
|
||
* Status atual: arquivo único contendo TODA a experiência Melissa
|
||
* (background + resumo + overlays). Em fases futuras será dividido em:
|
||
* - MelissaLayout.vue (wrapper: bg + dim + ψ + overlays + slot)
|
||
* - MelissaResumo.vue (conteúdo do resumo: clock, greeting, timeline, cards)
|
||
*
|
||
* Dados ainda em mock. Próxima fase pluga Supabase.
|
||
*
|
||
* Rota: /melissa (com :secao? opcional). User com layout_variant='melissa'
|
||
* em user_settings e' redirecionado da home do role pra ca pelo router.
|
||
*/
|
||
import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { useToast } from 'primevue/usetoast';
|
||
import { useConfirm } from 'primevue/useconfirm';
|
||
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';
|
||
import MelissaBusca from './MelissaBusca.vue';
|
||
import MelissaMenu from './MelissaMenu.vue';
|
||
import MelissaSettingsPanel from './MelissaSettingsPanel.vue';
|
||
import MelissaHeroClock from './MelissaHeroClock.vue';
|
||
import MelissaTimelineHoje from './MelissaTimelineHoje.vue';
|
||
import { useMelissaWallpaper } from './composables/useMelissaWallpaper';
|
||
import MelissaAgenda from './MelissaAgenda.vue';
|
||
import MelissaPacientes from './MelissaPacientes.vue';
|
||
import MelissaCompromissos from './MelissaCompromissos.vue';
|
||
import MelissaRecorrencias from './MelissaRecorrencias.vue';
|
||
import MelissaConversas from './MelissaConversas.vue';
|
||
import MelissaTags from './MelissaTags.vue';
|
||
import MelissaGrupos from './MelissaGrupos.vue';
|
||
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
|
||
import MelissaPerfil from './MelissaPerfil.vue';
|
||
import MelissaPaciente from './MelissaPaciente.vue';
|
||
import MelissaPlano from './MelissaPlano.vue';
|
||
import MelissaNegocio from './MelissaNegocio.vue';
|
||
import MelissaAlterarPlano from './MelissaAlterarPlano.vue';
|
||
import MelissaSeguranca from './MelissaSeguranca.vue';
|
||
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.
|
||
import MelissaEmbed from './MelissaEmbed.vue';
|
||
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
|
||
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
|
||
import MelissaLinkExterno from './MelissaLinkExterno.vue';
|
||
import MelissaNotificacoes from './MelissaNotificacoes.vue';
|
||
import MelissaFinanceiro from './MelissaFinanceiro.vue';
|
||
import MelissaFinanceiroLancamentos from './MelissaFinanceiroLancamentos.vue';
|
||
import MelissaDocumentos from './MelissaDocumentos.vue';
|
||
import MelissaDocumentosTemplates from './MelissaDocumentosTemplates.vue';
|
||
import MelissaRelatorios from './MelissaRelatorios.vue';
|
||
import MelissaMedicos from './MelissaMedicos.vue';
|
||
import MelissaEventoPanel from './MelissaEventoPanel.vue';
|
||
import { TOQUE_IDS, useMelissaToques } from './composables/useMelissaToques';
|
||
import { useMelissaPacientes } from './composables/useMelissaPacientes';
|
||
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
|
||
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 { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
||
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';
|
||
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
|
||
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
|
||
// Topbar system actions trazidos do AppTopbar pra Melissa: plan switcher
|
||
// (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa
|
||
// (fullscreen), então duplicamos os triggers + drawers aqui.
|
||
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue';
|
||
import { useNotifications } from '@/composables/useNotifications';
|
||
import { useNotificationStore } from '@/stores/notificationStore';
|
||
import { useAjuda } from '@/composables/useAjuda';
|
||
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
|
||
|
||
// Pacientes + eventos do dia.
|
||
//
|
||
// PERF: quando o usuário entra direto numa seção (`/melissa/agenda`,
|
||
// `/melissa/pacientes`...), o resumo fica tapado pelo conteúdo da seção.
|
||
// Carregar os dois loaders na hora competiria com as queries da seção
|
||
// (que é o que o user efetivamente vai ver). Adiamos os loaders do
|
||
// resumo pra rodarem em background via `requestIdleCallback` quando a
|
||
// rota inicial já tem seção. Quando o user fecha a seção (volta pro
|
||
// resumo), o cache provavelmente já tá quente — se não estiver, o
|
||
// loading aparece naturalmente.
|
||
//
|
||
// CACHE: composables usam stale-while-revalidate via melissaCacheStore.
|
||
// Reabertura do Melissa na mesma sessão SPA é instantânea.
|
||
// Snapshot da rota no setup pra detectar deep-link com seção já no boot.
|
||
// (`route` reativo é declarado mais abaixo, mas só precisamos do params
|
||
// inicial aqui — `setup` roda 1× por mount, params do router já estão
|
||
// resolvidos nesse ponto.)
|
||
const _hasInitialSecao = !!useRoute().params?.secao;
|
||
const {
|
||
pacientes: pacientesReais,
|
||
loading: pacientesLoading,
|
||
refetch: refetchPacientes,
|
||
fetchCached: fetchPacientesCached
|
||
} = useMelissaPacientes({ autoFetch: !_hasInitialSecao });
|
||
const {
|
||
eventos: eventosHojeReais,
|
||
refetch: refetchEventosHoje,
|
||
fetchCached: fetchEventosHojeCached
|
||
} = useMelissaEventosHoje({ autoFetch: !_hasInitialSecao });
|
||
|
||
// Defer manual quando a rota inicial é uma seção: agenda os fetches do
|
||
// resumo pra rodarem após a seção montar (idle callback) — fetchCached
|
||
// usa stale-while-revalidate, então não tomba o cache. setTimeout 200
|
||
// como fallback pra navegadores sem requestIdleCallback (Safari < 16).
|
||
if (_hasInitialSecao) {
|
||
const _idleFetch = () => {
|
||
fetchPacientesCached();
|
||
fetchEventosHojeCached();
|
||
};
|
||
if (typeof window !== 'undefined') {
|
||
if (typeof window.requestIdleCallback === 'function') {
|
||
window.requestIdleCallback(_idleFetch, { timeout: 1500 });
|
||
} else {
|
||
setTimeout(_idleFetch, 200);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Catálogo de cards do resumo (extensível — novos cards entram aqui)
|
||
// ───────────────────────────────────────────────────────────────
|
||
const CARDS_CATALOG = [
|
||
{ id: 'proximo-paciente', label: 'Próximo paciente', icon: 'pi pi-user', implementado: true },
|
||
{ id: 'whatsapp', label: 'WhatsApp', icon: 'pi pi-whatsapp', implementado: true },
|
||
{ id: 'recebiveis', label: 'Recebíveis hoje', icon: 'pi pi-wallet', implementado: true },
|
||
{ id: 'copilot', label: 'Sugestão Copilot', icon: 'pi pi-sparkles', implementado: false },
|
||
{ id: 'agenda-amanha', label: 'Agenda de amanhã', icon: 'pi pi-calendar', implementado: false },
|
||
{ id: 'aniversariantes', label: 'Aniversariantes', icon: 'pi pi-gift', implementado: false },
|
||
{ id: 'faltas-recentes', label: 'Faltas recentes', icon: 'pi pi-exclamation-circle', implementado: false },
|
||
{ id: 'tempo-resposta', label: 'Tempo médio de resposta', icon: 'pi pi-stopwatch', implementado: false }
|
||
];
|
||
const CARD_IDS = new Set(CARDS_CATALOG.map((c) => c.id));
|
||
|
||
// User config — quais cards estão ativos e em que ordem; modo de visualização
|
||
const cardsAtivos = ref(['proximo-paciente', 'whatsapp', 'recebiveis', 'copilot']);
|
||
const cardsLayout = ref('linha-unica'); // 'linha-unica' | 'duas-linhas'
|
||
const centralOpen = ref(false);
|
||
|
||
function toggleCardAtivo(id) {
|
||
const idx = cardsAtivos.value.indexOf(id);
|
||
if (idx >= 0) cardsAtivos.value.splice(idx, 1);
|
||
else cardsAtivos.value.push(id);
|
||
}
|
||
|
||
function getCatalogItem(id) {
|
||
return CARDS_CATALOG.find((c) => c.id === id);
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Seções (overlay sobre o resumo — não saem do layout Melissa)
|
||
// ───────────────────────────────────────────────────────────────
|
||
const SECOES = {
|
||
agenda: { label: 'Agenda', icon: 'pi pi-calendar', descricao: 'Sessões agendadas, recorrências, compromissos especiais.' },
|
||
pacientes: { label: 'Pacientes', icon: 'pi pi-users', descricao: 'Cadastro, grupos, tags, prontuários e histórico.' },
|
||
conversas: { label: 'WhatsApp', icon: 'pi pi-whatsapp', descricao: 'Conversas em andamento, templates, automações e status.' },
|
||
prontuarios: { label: 'Prontuários', icon: 'pi pi-file', descricao: 'Documentos clínicos, modelos e histórico por paciente.' },
|
||
financeiro: { label: 'Financeiro', icon: 'pi pi-wallet', descricao: 'Recebíveis, lançamentos, relatórios e conciliação.' },
|
||
configuracoes: { label: 'Configurações', icon: 'pi pi-cog', descricao: 'Preferências, integrações e equipe.' },
|
||
copilot: { label: 'Copilot', icon: 'pi pi-sparkles', descricao: 'Insights da IA, sugestões e automações inteligentes.' },
|
||
// Sub-itens da categoria "Agenda e Pacientes" do menu
|
||
'cadastros-recebidos': { label: 'Cadastros recebidos', icon: 'pi pi-inbox', descricao: 'Solicitações vindas do agendador online.' },
|
||
recorrencias: { label: 'Recorrências', icon: 'pi pi-sync', descricao: 'Sessões recorrentes e séries semanais.' },
|
||
compromissos: { label: 'Compromissos determinados', icon: 'pi pi-flag', descricao: 'Compromissos especiais com janelas próprias.' },
|
||
grupos: { label: 'Grupos de pacientes', icon: 'pi pi-th-large', descricao: 'Categorize pacientes por grupos.' },
|
||
tags: { label: 'Tags', icon: 'pi pi-tag', descricao: 'Etiquetas livres pra organizar pacientes.' },
|
||
medicos: { label: 'Médicos e referências', icon: 'pi pi-user-edit', descricao: 'Profissionais que encaminham ou recebem pacientes.' },
|
||
// Pagina nativa do prontuario do paciente (MelissaPaciente) — Fase 1 foundation.
|
||
// ID do paciente vem via route.query.id (?id=xxx). Substitui gradualmente o
|
||
// PatientProntuario.vue legado (3593L Dialog) que continua nos 4 callsites
|
||
// ate Fase 8 (wire-up final).
|
||
paciente: { label: 'Paciente', icon: 'pi pi-user', descricao: 'Prontuario completo: visao geral, perfil, sessoes, financeiro, documentos, conversas.' },
|
||
// Pagina unificada do Layout Melissa (Aparencia + Fundo + Relogio + Cronometro)
|
||
aparencia: { label: 'Layout Melissa', icon: 'pi pi-palette', descricao: 'Aparência, plano de fundo, relógio e cronômetro do resumo.' },
|
||
// Pagina nativa do perfil (MelissaPerfil) — saiu do MelissaConfiguracoes
|
||
perfil: { label: 'Meu Perfil', icon: 'pi pi-user', descricao: 'Identidade, contato, bio, redes — gamificacao no aside.' },
|
||
// Pagina nativa do plano (MelissaPlano) — saiu do MelissaConfiguracoes
|
||
plano: { label: 'Meu Plano', icon: 'pi pi-credit-card', descricao: 'Assinatura, recursos liberados e historico de mudancas.' },
|
||
// Pagina nativa do negocio (MelissaNegocio) — saiu do MelissaConfiguracoes
|
||
negocio: { label: 'Meu Negócio', icon: 'pi pi-briefcase', descricao: 'Identidade, fiscal, endereco, contato, redes — gamificacao no aside.' },
|
||
// Pagina nativa de seguranca (MelissaSeguranca) — saiu do MelissaConfiguracoes
|
||
seguranca: { label: 'Segurança', icon: 'pi pi-shield', descricao: 'Trocar senha + boas praticas + estado da sessao.' },
|
||
// Pagina nativa de bloqueios (MelissaBloqueios) — saiu do MelissaConfiguracoes
|
||
bloqueios: { label: 'Bloqueios e Feriados', icon: 'pi pi-ban', descricao: 'Feriados nacionais (auto), municipais e bloqueios manuais.' },
|
||
// Pagina nativa de configs da agenda (MelissaAgendaConfig) — saiu do MelissaConfiguracoes
|
||
'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)
|
||
'financeiro': { label: 'Financeiro', icon: 'pi pi-wallet', descricao: 'Visão geral, recebíveis e indicadores.' },
|
||
'financeiro-lancamentos': { label: 'Lançamentos financeiros', icon: 'pi pi-list', descricao: 'Cobranças, pagamentos e recebimentos.' },
|
||
'documentos': { label: 'Documentos', icon: 'pi pi-file', descricao: 'Documentos clínicos do tenant.' },
|
||
'documentos-templates': { label: 'Templates de documentos', icon: 'pi pi-file-edit', descricao: 'Modelos pra prontuários e relatórios.' },
|
||
'agendamentos-recebidos': { label: 'Agendamentos recebidos', icon: 'pi pi-inbox', descricao: 'Solicitações do agendador online.' },
|
||
'online-scheduling': { label: 'Agendador online', icon: 'pi pi-calendar-clock', descricao: 'Configure o link publico, identidade visual, fluxo, pagamento e textos do agendador.' },
|
||
'relatorios': { label: 'Relatórios', icon: 'pi pi-chart-bar', descricao: 'Indicadores clínicos e financeiros do tenant.' },
|
||
'notificacoes': { label: 'Notificações', icon: 'pi pi-bell', descricao: 'Histórico de notificações enviadas.' },
|
||
'link-externo': { label: 'Link externo de cadastro', icon: 'pi pi-share-alt', descricao: 'Link público pra pacientes se cadastrarem.' }
|
||
};
|
||
|
||
// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna).
|
||
// Todas foram promovidas pra paginas nativas Melissa.
|
||
const MELISSA_EMBED_KEYS = [];
|
||
|
||
// Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes,
|
||
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
|
||
// evitar colisão (ex: /melissa/agenda → MelissaAgenda, não config).
|
||
const MELISSA_NON_CONFIG_SLUGS = new Set([
|
||
'agenda', 'pacientes', 'paciente', 'compromissos', 'recorrencias', 'conversas',
|
||
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
|
||
'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos',
|
||
'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.
|
||
// Mantidos sincronizados com o ROUTE_ALIASES/INLINE_KEYS de lá.
|
||
const MELISSA_CONFIG_ALIASES = new Set([
|
||
'aparencia',
|
||
'fundo', 'relogio', 'cronometro'
|
||
]);
|
||
function isMelissaConfigRoute(slug) {
|
||
if (!slug) return false;
|
||
const s = String(slug).toLowerCase();
|
||
if (MELISSA_NON_CONFIG_SLUGS.has(s)) return false;
|
||
if (MELISSA_CONFIG_ALIASES.has(s)) return true;
|
||
// Slugs diretos: cfg-precificacao, cfg-agenda, cfg-bloqueios, etc.
|
||
return s.startsWith('cfg-');
|
||
}
|
||
|
||
// Seção ativa = param `:secao?` da rota. URL é a fonte da verdade pra
|
||
// permitir back/forward e deep-link (ex: /melissa/agenda abre o
|
||
// overlay da agenda direto). Se a chave for inválida, vira null.
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const secaoAberta = computed(() => {
|
||
const s = route.params?.secao;
|
||
if (!s) return null;
|
||
// Aceita: keys conhecidas em SECOES (páginas dedicadas + 5 aliases legados)
|
||
if (SECOES[s]) return s;
|
||
// Aceita: rotas de config (fundo/relogio/cronometro inline e qualquer
|
||
// cfg-* embed). Sem isso, /melissa/fundo viraria null e fecharia a
|
||
// página de configs ao clicar nesses itens da sidebar.
|
||
if (isMelissaConfigRoute(s)) return s;
|
||
return null;
|
||
});
|
||
|
||
// (Removido) showConfigSidebar / configAsideLeftStyle — o sidebar
|
||
// global de configs foi substituido pelo MelissaConfigPopover
|
||
// dentro de cada pagina, ancorado no botao "Configuracoes" no
|
||
// topo da .xxx-side. Sem mais CSS var de left dinamico nas pages.
|
||
|
||
// Quando o usuário fecha a seção e volta pro resumo, garante que os
|
||
// dados estão prontos (caso o idle callback ainda não tenha disparado
|
||
// no fluxo deep-link). fetchCached é idempotente: cache hit → instant,
|
||
// cache miss → fetch real. Sem cache, não dispara nada estranho.
|
||
watch(secaoAberta, (newVal, oldVal) => {
|
||
if (oldVal && !newVal) {
|
||
fetchPacientesCached();
|
||
fetchEventosHojeCached();
|
||
}
|
||
});
|
||
|
||
function abrirSecao(key) {
|
||
// Fecha overlays paralelos pra evitar empilhamento
|
||
workspaceOpen.value = false;
|
||
eventoSelecionado.value = null;
|
||
if (key === secaoAberta.value) return; // no-op, evita push duplicado
|
||
router.push({ name: 'Melissa', params: { secao: key } });
|
||
}
|
||
function fecharSecao() {
|
||
if (!secaoAberta.value) return;
|
||
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);
|
||
const pinContextSlug = ref('');
|
||
|
||
// Toda vez que a seção muda, registra como "recente" no dock (se não
|
||
// for builtin nem pinned). Slugs de configuração (cfg-*) também viram
|
||
// recent — útil quando o user fica navegando entre páginas de config.
|
||
watch(secaoAberta, (slug) => {
|
||
if (slug) dockPins.pushRecent(slug);
|
||
});
|
||
|
||
function openPinContextMenu(event, slug) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
pinContextSlug.value = slug;
|
||
pinContextMenu.value?.show(event);
|
||
}
|
||
|
||
const pinContextMenuItems = computed(() => {
|
||
const slug = pinContextSlug.value;
|
||
if (!slug) return [];
|
||
const isPinned = dockPins.isPinned(slug);
|
||
const items = [];
|
||
if (isPinned) {
|
||
items.push({
|
||
label: 'Desafixar',
|
||
icon: 'pi pi-bookmark',
|
||
command: () => dockPins.unpin(slug)
|
||
});
|
||
} else {
|
||
items.push({
|
||
label: 'Fixar no dock',
|
||
icon: 'pi pi-bookmark-fill',
|
||
command: () => {
|
||
const result = dockPins.pin(slug);
|
||
if (!result.ok && result.reason === 'full') {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Limite atingido',
|
||
detail: `Você pode fixar até ${dockPins.MAX_PINNED} atalhos. Desafixe um pra liberar espaço.`,
|
||
life: 3500
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
items.push({ separator: true });
|
||
items.push({
|
||
label: 'Remover do dock',
|
||
icon: 'pi pi-times',
|
||
command: () => dockPins.remove(slug)
|
||
});
|
||
return items;
|
||
});
|
||
|
||
// Resolve label/ícone a partir do slug pra renderizar no pin.
|
||
// Usa o catálogo SECOES (já existente) — fallback genérico se slug
|
||
// for de uma rota cfg-* (configurações embeds).
|
||
function pinMeta(slug) {
|
||
const fromSecoes = SECOES[slug];
|
||
if (fromSecoes) return { label: fromSecoes.label, icon: fromSecoes.icon };
|
||
if (slug?.startsWith('cfg-')) {
|
||
return { label: 'Configuração', icon: 'pi pi-cog' };
|
||
}
|
||
return { label: slug, icon: 'pi pi-bookmark' };
|
||
}
|
||
|
||
// Prefs de layout/UI (toque, fundo, opacidade, formato hora)
|
||
// TODO: migrar pra configs do tenant — hoje só localStorage pra survive refresh
|
||
const LAYOUT_STORAGE_KEY = 'melissa.layout.v1';
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Relógio (atualiza a cada segundo)
|
||
// ───────────────────────────────────────────────────────────────
|
||
const now = ref(new Date());
|
||
let clockTimer = null;
|
||
|
||
// layoutReady gate: MelissaAgenda/MelissaPacientes têm <Teleport to=".melissa-dock">
|
||
// (target vive aqui no MelissaLayout). Vue exige que o target esteja no DOM
|
||
// antes do Teleport montar. No deep-link (/melissa/agenda) os dois montam
|
||
// no mesmo render pass — race. Atrasar o mount em 1 nextTick garante que
|
||
// .melissa-dock já está no DOM. Evita "Cannot read properties of null
|
||
// (reading 'insertBefore')" em moveTeleport durante triggers reativos
|
||
// (ex: setTheme do PrimeVue) na fase de bootstrap.
|
||
const layoutReady = ref(false);
|
||
|
||
onMounted(() => {
|
||
clockTimer = setInterval(() => (now.value = new Date()), 1000);
|
||
window.addEventListener('keydown', onKeydown);
|
||
nextTick(() => { layoutReady.value = true; });
|
||
});
|
||
onBeforeUnmount(() => {
|
||
if (clockTimer) clearInterval(clockTimer);
|
||
window.removeEventListener('keydown', onKeydown);
|
||
});
|
||
|
||
const use24h = ref(true);
|
||
const horaFormatada = computed(() => {
|
||
const d = now.value;
|
||
if (use24h.value) {
|
||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
let h = d.getHours();
|
||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||
h = h % 12 || 12;
|
||
return `${h}:${String(d.getMinutes()).padStart(2, '0')} ${ampm}`;
|
||
});
|
||
|
||
const dataExtenso = computed(() => {
|
||
return now.value.toLocaleDateString('pt-BR', {
|
||
weekday: 'long',
|
||
day: 'numeric',
|
||
month: 'long'
|
||
});
|
||
});
|
||
|
||
const saudacao = computed(() => {
|
||
const h = now.value.getHours();
|
||
if (h >= 5 && h < 12) return 'Bom dia';
|
||
if (h >= 12 && h < 18) return 'Boa tarde';
|
||
return 'Boa noite';
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Background customizável (composable)
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Refs (bgUrl/overlayOpacity/bgImageOpacity) sao criados pelo
|
||
// composable; persistencia continua aqui no pai (saveLayoutPrefs +
|
||
// applyPrefsPayload + watcher) pra agrupar com outras prefs.
|
||
const {
|
||
bgUrl,
|
||
overlayOpacity,
|
||
bgImageOpacity,
|
||
defaultBgStyle,
|
||
photoStyle,
|
||
onFileChange,
|
||
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
|
||
// ───────────────────────────────────────────────────────────────
|
||
// `toggleDarkMode` flipa a classe .app-dark + layoutConfig.darkTheme.
|
||
// `applyThemeEngine` re-aplica o preset com nova primary/surface.
|
||
// `userSettings.queuePatch` persiste no DB (debounced upsert em
|
||
// user_settings — colunas theme_mode/primary_color, não toca em melissa_prefs).
|
||
const { layoutConfig, toggleDarkMode, isDarkTheme } = useLayout();
|
||
const userSettings = useUserSettingsPersistence();
|
||
onMounted(() => userSettings.init());
|
||
|
||
// Paleta enxuta — espelha primaryColors da ProfilePage. 'noir' usa
|
||
// currentColor (preto/branco conforme tema) — visualmente vira a primary
|
||
// "neutra" pro user que quer monocromático.
|
||
const PRIMARY_COLORS = [
|
||
{ name: 'noir', swatch: 'currentColor' },
|
||
{ name: 'emerald', swatch: '#10b981' },
|
||
{ name: 'green', swatch: '#22c55e' },
|
||
{ name: 'lime', swatch: '#84cc16' },
|
||
{ name: 'orange', swatch: '#f97316' },
|
||
{ name: 'amber', swatch: '#f59e0b' },
|
||
{ name: 'yellow', swatch: '#eab308' },
|
||
{ name: 'teal', swatch: '#14b8a6' },
|
||
{ name: 'cyan', swatch: '#06b6d4' },
|
||
{ name: 'sky', swatch: '#0ea5e9' },
|
||
{ name: 'blue', swatch: '#3b82f6' },
|
||
{ name: 'indigo', swatch: '#6366f1' },
|
||
{ name: 'violet', swatch: '#8b5cf6' },
|
||
{ name: 'purple', swatch: '#a855f7' },
|
||
{ name: 'fuchsia', swatch: '#d946ef' },
|
||
{ name: 'pink', swatch: '#ec4899' },
|
||
{ name: 'rose', swatch: '#f43f5e' }
|
||
];
|
||
|
||
function setDark(shouldBeDark) {
|
||
if (isDarkTheme.value === shouldBeDark) return;
|
||
toggleDarkMode();
|
||
userSettings.queuePatch({ theme_mode: shouldBeDark ? 'dark' : 'light' });
|
||
}
|
||
|
||
function setPrimary(name) {
|
||
if (!name || layoutConfig.primary === name) return;
|
||
layoutConfig.primary = name;
|
||
applyThemeEngine(layoutConfig);
|
||
userSettings.queuePatch({ primary_color: name });
|
||
}
|
||
|
||
// Surface = base de fundo das superfícies (cards, dialog body, etc).
|
||
// Usa a mesma infra de tema do app — applyThemeEngine + persist em user_settings.
|
||
const SURFACES = THEME_SURFACES;
|
||
const activeSurface = computed(() => layoutConfig.surface || (isDarkTheme.value ? 'zinc' : 'slate'));
|
||
function setSurface(name) {
|
||
if (!name || layoutConfig.surface === name) return;
|
||
layoutConfig.surface = name;
|
||
applyThemeEngine(layoutConfig);
|
||
userSettings.queuePatch({ surface_color: name });
|
||
}
|
||
|
||
// Preset = engine de tokens do PrimeVue. Mantemos so Lara e Nora
|
||
// (Aura ficou de fora — decisao de design pra reduzir ruido visual).
|
||
const PRESETS = THEME_PRESETS.filter((p) => p === 'Lara' || p === 'Nora');
|
||
function setPreset(name) {
|
||
if (!name || layoutConfig.preset === name) return;
|
||
layoutConfig.preset = name;
|
||
applyThemeEngine(layoutConfig);
|
||
userSettings.queuePatch({ preset: name });
|
||
}
|
||
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Settings popover (canto superior direito)
|
||
// ───────────────────────────────────────────────────────────────
|
||
const settingsOpen = ref(false);
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Timeline horizontal — range/eco/posicoes/auto-scroll/cursor "Agora"
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Tudo (HORA_INICIO/FIM, hoursRange, eventosVisiveis, isFolga, todayFeriado,
|
||
// tlHScrollEl, tlEcoState, etc.) migrou pro componente MelissaTimelineHoje.
|
||
// Pai so passa eventos brutos + workRules/settings/feriados via props,
|
||
// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo).
|
||
|
||
// Contagens por tipo + frase resumo do dia
|
||
const contagensDia = computed(() => {
|
||
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
|
||
for (const ev of eventosHojeReais.value) c[ev.tipo] = (c[ev.tipo] || 0) + 1;
|
||
return c;
|
||
});
|
||
|
||
function pluralizar(n, singular, plural) {
|
||
return `${n} ${n === 1 ? singular : plural}`;
|
||
}
|
||
|
||
// Partes estruturadas pro template renderizar cada contagem como link clicável
|
||
const resumoPartes = computed(() => {
|
||
const c = contagensDia.value;
|
||
const partes = [];
|
||
if (c.sessao > 0) partes.push({ tipo: 'sessao', text: pluralizar(c.sessao, 'atendimento', 'atendimentos') });
|
||
if (c.supervisao > 0) partes.push({ tipo: 'supervisao', text: pluralizar(c.supervisao, 'supervisão', 'supervisões') });
|
||
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
|
||
return partes;
|
||
});
|
||
|
||
// Evento selecionado (dialog de detalhes)
|
||
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 confirm = useConfirm();
|
||
const tenantStore = useTenantStore();
|
||
const { gerarCobrancaManual } = useAgendaFinanceiro();
|
||
const conversationDrawerStore = useConversationDrawerStore();
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Agenda completa (CRUD + recorrência via AgendaEventDialog)
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Composable injetado em MelissaAgenda via provide/inject — orquestra
|
||
// useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados
|
||
// e expõe dialog state + handlers (save/delete/série/persistMoveOrResize).
|
||
// MelissaAgenda lê M.eventos pra alimentar o FullCalendar e mutará
|
||
// M.viewStart/M.viewEnd via FC.datesSet.
|
||
const M = useMelissaAgenda();
|
||
provide(MELISSA_AGENDA_KEY, M);
|
||
|
||
// Destrutura refs/computeds pro template auto-unwrappar (refs em objetos
|
||
// aninhados NÃO unwrappam no template — só os top-level do setup).
|
||
const {
|
||
dialogOpen: agendaDialogOpen,
|
||
dialogEventRow: agendaDialogEventRow,
|
||
dialogStartISO: agendaDialogStartISO,
|
||
dialogEndISO: agendaDialogEndISO,
|
||
dialogBlockOverlap: agendaDialogBlockOverlap,
|
||
occDialogOpen: agendaOccDialogOpen,
|
||
occDialogEventRow: agendaOccDialogEventRow,
|
||
occDialogStartISO: agendaOccDialogStartISO,
|
||
occDialogEndISO: agendaOccDialogEndISO,
|
||
serieRefreshTick: agendaSerieRefreshTick,
|
||
ownerId: agendaOwnerId,
|
||
clinicTenantId: agendaClinicTenantId,
|
||
commitmentOptions: agendaCommitmentOptions,
|
||
workRules: agendaWorkRules,
|
||
settings: agendaSettings,
|
||
allEventsForDialog: agendaAllEvents,
|
||
feriados: agendaFeriados,
|
||
bloqueioDialogOpen: agendaBloqueioOpen,
|
||
bloqueioMode: agendaBloqueioMode,
|
||
// Status change confirm dialog (Fase 5, 2026-05-14)
|
||
statusDialogOpen: agendaStatusDialogOpen,
|
||
statusDialogProps: agendaStatusDialogProps,
|
||
onStatusDialogConfirm: agendaOnStatusDialogConfirm,
|
||
onStatusDialogCancel: agendaOnStatusDialogCancel
|
||
} = M;
|
||
|
||
function abrirEvento(ev) {
|
||
eventoSelecionado.value = ev;
|
||
}
|
||
function fecharEvento() {
|
||
eventoSelecionado.value = null;
|
||
eventoBusy.value = false;
|
||
}
|
||
|
||
// ── Actions do MelissaEventoPanel ──────────────────────────────
|
||
// Fase 5 (2026-05-14): TODOS os status (realizado/faltou/cancelado/etc)
|
||
// passam por M.onUpdateSeriesEvent — que abre o AgendaStatusChangeConfirmDialog
|
||
// quando há regra de exceção, pacote saldo ou pending record. Antes, eventos
|
||
// reais faziam UPDATE direto sem passar pelo dialog (gap reportado pelo user).
|
||
async function updateEventoStatus(novoStatus, msgSucesso) {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.id || eventoBusy.value) return;
|
||
eventoBusy.value = true;
|
||
try {
|
||
const isVirtual =
|
||
!!ev.is_occurrence ||
|
||
(typeof ev.id === 'string' && ev.id.startsWith('rec::'));
|
||
|
||
await M.onUpdateSeriesEvent({
|
||
id: isVirtual ? null : ev.id,
|
||
status: novoStatus,
|
||
recurrence_date:
|
||
ev.recurrence_date ||
|
||
ev.original_date ||
|
||
String(ev.inicio_em || '').slice(0, 10),
|
||
inicio_em: ev.inicio_em,
|
||
fim_em: ev.fim_em,
|
||
is_virtual: isVirtual,
|
||
row: ev
|
||
});
|
||
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
||
// Refetch:
|
||
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
|
||
// - refetchEventosHoje() alimenta a timeline do resumo (composable
|
||
// separado em useMelissaEventosHoje); sem isso o card visualmente
|
||
// não muda apesar do DB estar atualizado.
|
||
M.refetch();
|
||
refetchEventosHoje();
|
||
fecharEvento();
|
||
} catch (e) {
|
||
const msg = e?.message || 'Erro ao atualizar evento';
|
||
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
|
||
} finally {
|
||
eventoBusy.value = false;
|
||
}
|
||
}
|
||
|
||
function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como realizada'); }
|
||
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
|
||
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
|
||
|
||
// Excluir evento via popover (Fase 5, 2026-05-14). Regra: não permitir
|
||
// exclusão direta de ocorrência de série recorrente — usar fluxo do dialog
|
||
// pra encerrar série ou editar ocorrência. Pra avulsa: confirm + delete +
|
||
// remove cobranças vinculadas (com aviso explícito no confirm).
|
||
// Ver lançamentos da sessão — abre dialog com financial_records vinculados.
|
||
// Reusa o mesmo padrão do dialog dentro do AgendaEventDialog. 2026-05-14.
|
||
const lancamentosDialogOpen = ref(false);
|
||
const lancamentosList = ref([]);
|
||
const lancamentosLoading = ref(false);
|
||
const lancamentosEventoTitulo = ref('');
|
||
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
|
||
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
|
||
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
|
||
// sessions_used — só quando marcar Realizada depois.
|
||
const anteciparDialogOpen = ref(false);
|
||
const anteciparMethod = ref('pix');
|
||
const anteciparBusy = ref(false);
|
||
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
|
||
const anteciparMethodOptions = [
|
||
{ value: 'pix', label: 'Já recebi — PIX' },
|
||
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
|
||
{ value: 'deposito', label: 'Já recebi — Depósito' },
|
||
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' },
|
||
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' }
|
||
];
|
||
|
||
async function onAnteciparPagamento() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev) return;
|
||
// Valida: precisa ter paciente, valor (price)
|
||
if (!ev.patient_id || !ev.price) {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Não é possível antecipar',
|
||
detail: 'Sessão precisa ter paciente e valor configurado.',
|
||
life: 4000
|
||
});
|
||
return;
|
||
}
|
||
anteciparEventoRef.value = ev;
|
||
anteciparMethod.value = 'pix';
|
||
anteciparDialogOpen.value = true;
|
||
}
|
||
|
||
async function confirmAnteciparPagamento() {
|
||
const ev = anteciparEventoRef.value;
|
||
if (!ev || anteciparBusy.value) return;
|
||
anteciparBusy.value = true;
|
||
try {
|
||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||
const ownerId = ev.owner_id || ev.terapeuta_id || null;
|
||
const settlement = anteciparMethod.value;
|
||
const amount = Number(ev.price) || 0;
|
||
const dueIso = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||
|
||
// 1) Materializa se virtual (cria agenda_evento real com status='agendado')
|
||
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
|
||
const isVirtual = ev.is_occurrence || isVirtualId;
|
||
let eventoId = ev.id;
|
||
if (isVirtual) {
|
||
const rid = ev.recurrence_id || ev.serie_id || null;
|
||
const rDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
|
||
if (!rid || !rDate) throw new Error('Não foi possível identificar a regra de recorrência.');
|
||
// Confere se já não foi materializada
|
||
const { data: existing } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id')
|
||
.eq('recurrence_id', rid)
|
||
.eq('recurrence_date', rDate)
|
||
.maybeSingle();
|
||
if (existing?.id) {
|
||
eventoId = existing.id;
|
||
} else {
|
||
const { data: created, error: cErr } = await supabase
|
||
.from('agenda_eventos')
|
||
.insert({
|
||
owner_id: ownerId,
|
||
tenant_id: tenantId,
|
||
recurrence_id: rid,
|
||
recurrence_date: rDate,
|
||
tipo: 'sessao',
|
||
status: 'agendado',
|
||
titulo: ev.titulo || 'Sessão',
|
||
inicio_em: ev.inicio_em,
|
||
fim_em: ev.fim_em,
|
||
patient_id: ev.patient_id,
|
||
determined_commitment_id: ev.determined_commitment_id || null,
|
||
modalidade: ev.modalidade || 'presencial',
|
||
price: amount,
|
||
visibility_scope: 'public'
|
||
})
|
||
.select('id')
|
||
.single();
|
||
if (cErr) throw cErr;
|
||
eventoId = created.id;
|
||
}
|
||
}
|
||
|
||
// 2) Verifica se já tem financial_record vinculado
|
||
const { data: existRec } = await supabase
|
||
.from('financial_records')
|
||
.select('id, status')
|
||
.eq('agenda_evento_id', eventoId)
|
||
.is('deleted_at', null)
|
||
.order('created_at', { ascending: false })
|
||
.limit(1)
|
||
.maybeSingle();
|
||
if (existRec?.status === 'paid') {
|
||
toast.add({ severity: 'info', summary: 'Já está pago', detail: 'Esta sessão já tem cobrança paga.', life: 3500 });
|
||
return;
|
||
}
|
||
|
||
// 3) Cria record via RPC (ou usa existente pending pra marcar paid)
|
||
let recordId = existRec?.id || null;
|
||
if (!recordId) {
|
||
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
|
||
p_tenant_id: tenantId,
|
||
p_owner_id: ownerId,
|
||
p_patient_id: ev.patient_id,
|
||
p_agenda_evento_id: eventoId,
|
||
p_amount: amount,
|
||
p_due_date: dueIso
|
||
});
|
||
if (rpcErr) throw rpcErr;
|
||
const { data: newRec } = await supabase
|
||
.from('financial_records')
|
||
.select('id')
|
||
.eq('agenda_evento_id', eventoId)
|
||
.order('created_at', { ascending: false })
|
||
.limit(1)
|
||
.single();
|
||
recordId = newRec?.id;
|
||
}
|
||
|
||
// 4) Aplica status conforme settlement
|
||
if (recordId) {
|
||
const patch = { updated_at: new Date().toISOString() };
|
||
if (settlement === 'link') {
|
||
patch.payment_method = 'asaas';
|
||
// status fica pending
|
||
} else {
|
||
patch.status = 'paid';
|
||
patch.paid_at = new Date().toISOString();
|
||
patch.payment_method = settlement;
|
||
}
|
||
await supabase.from('financial_records').update(patch).eq('id', recordId);
|
||
}
|
||
|
||
const methodLabel = anteciparMethodOptions.find((o) => o.value === settlement)?.label || settlement;
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: settlement === 'link' ? 'Cobrança gerada' : 'Pagamento registrado',
|
||
detail: `R$ ${amount.toFixed(2).replace('.', ',')} — ${methodLabel}`,
|
||
life: 4000
|
||
});
|
||
anteciparDialogOpen.value = false;
|
||
M.refetch();
|
||
refetchEventosHoje();
|
||
fecharEvento();
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao antecipar pagamento.', life: 5000 });
|
||
} finally {
|
||
anteciparBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function onVerLancamentos() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.id) return;
|
||
// Ocorrência virtual ainda não foi materializada — id é sintético
|
||
// `rec::<rule>::<date>`, não bate com agenda_evento_id (uuid).
|
||
// Aborta sem query e avisa o user. 2026-05-14.
|
||
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
|
||
if (ev.is_occurrence || isVirtualId) {
|
||
toast.add({
|
||
severity: 'info',
|
||
summary: 'Sem lançamentos ainda',
|
||
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
|
||
life: 5000
|
||
});
|
||
return;
|
||
}
|
||
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
|
||
lancamentosDialogOpen.value = true;
|
||
lancamentosLoading.value = true;
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('financial_records')
|
||
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
|
||
.eq('agenda_evento_id', ev.id)
|
||
.is('deleted_at', null)
|
||
.order('created_at', { ascending: true });
|
||
if (error) throw error;
|
||
lancamentosList.value = data || [];
|
||
} catch (e) {
|
||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao carregar lançamentos.', life: 4000 });
|
||
lancamentosList.value = [];
|
||
} finally {
|
||
lancamentosLoading.value = false;
|
||
}
|
||
}
|
||
function _fmtLancBRL(v) {
|
||
return Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||
}
|
||
function _fmtLancDate(d) {
|
||
if (!d) return '—';
|
||
try { return new Date(d).toLocaleDateString('pt-BR'); } catch { return '—'; }
|
||
}
|
||
const _lancMethodLabels = {
|
||
pix: 'PIX', dinheiro: 'Dinheiro', deposito: 'Depósito', cartao: 'Cartão',
|
||
cartao_maquininha: 'Cartão (maquininha)', convenio: 'Convênio', asaas: 'Asaas'
|
||
};
|
||
const _lancStatusLabels = {
|
||
pending: 'Pendente', paid: 'Pago', overdue: 'Vencido',
|
||
cancelled: 'Cancelado', refunded: 'Reembolsado', partial: 'Parcial'
|
||
};
|
||
function _lancStatusSeverity(s) {
|
||
return { pending: 'info', paid: 'success', overdue: 'danger', cancelled: 'secondary', refunded: 'warn', partial: 'warn' }[s] || 'secondary';
|
||
}
|
||
|
||
async function onDeleteEvento() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.id || eventoBusy.value) return;
|
||
|
||
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
|
||
const isVirtual = ev.is_occurrence || isVirtualId;
|
||
|
||
// ── Ocorrência virtual: cria recurrence_exception (cancel_session) ──
|
||
// Sem interação ainda — segura excluir. Mostra confirm simples.
|
||
if (isVirtual) {
|
||
const recId = ev.recurrence_id || ev.serie_id;
|
||
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
|
||
if (!recId || !origDate) {
|
||
toast.add({ severity: 'warn', summary: 'Não excluível', detail: 'Não foi possível identificar a regra de recorrência.', life: 4000 });
|
||
return;
|
||
}
|
||
confirm.require({
|
||
header: 'Cancelar ocorrência',
|
||
message: 'Esta ocorrência ainda não tem cobranças. Tem certeza que deseja cancelá-la? Ela some da agenda; as outras sessões da série continuam.',
|
||
icon: 'pi pi-exclamation-triangle',
|
||
acceptLabel: 'Sim, cancelar',
|
||
rejectLabel: 'Manter',
|
||
acceptClass: 'p-button-danger',
|
||
accept: async () => {
|
||
eventoBusy.value = true;
|
||
try {
|
||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||
// upsert pra ser idempotente: se já existe exception
|
||
// pra (recurrence_id, original_date) — edição anterior,
|
||
// conflito de criação, cancel duplicado — sobrescreve em
|
||
// vez de quebrar com unique violation.
|
||
const { error } = await supabase.from('recurrence_exceptions').upsert({
|
||
recurrence_id: recId,
|
||
tenant_id: tenantId,
|
||
original_date: origDate,
|
||
type: 'cancel_session',
|
||
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
|
||
}, { onConflict: 'recurrence_id,original_date' });
|
||
if (error) throw error;
|
||
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
|
||
M.refetch();
|
||
refetchEventosHoje();
|
||
fecharEvento();
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 });
|
||
} finally {
|
||
eventoBusy.value = false;
|
||
}
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ── Evento real: conta cobranças vinculadas ──
|
||
let recordsCount = 0;
|
||
let hasPaidRecord = false;
|
||
try {
|
||
const { data } = await supabase
|
||
.from('financial_records')
|
||
.select('id, status')
|
||
.eq('agenda_evento_id', ev.id)
|
||
.is('deleted_at', null);
|
||
recordsCount = (data || []).length;
|
||
hasPaidRecord = (data || []).some((r) => r.status === 'paid');
|
||
} catch (e) {
|
||
console.warn('[Excluir sessão] erro contando records:', e?.message);
|
||
}
|
||
|
||
// Cobrança PAGA bloqueia exclusão — precisa estornar pelo Financeiro
|
||
if (hasPaidRecord) {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Sessão com pagamento confirmado',
|
||
detail: 'Esta sessão tem cobrança paga. Estorne primeiro pelo Financeiro antes de excluir.',
|
||
life: 5500
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Evento de série materializado (tem recurrence_id) → vira exception
|
||
// (cancel_session) também, mas removendo records pendentes junto.
|
||
const isMaterializedOccurrence = !!ev.recurrence_id || !!ev.serie_id;
|
||
const msgRecords = recordsCount > 0
|
||
? `Esta sessão tem ${recordsCount} cobrança(s) pendente(s) que também será(ão) removida(s).`
|
||
: 'A sessão não tem cobranças vinculadas.';
|
||
|
||
confirm.require({
|
||
header: isMaterializedOccurrence ? 'Cancelar ocorrência' : 'Excluir sessão',
|
||
message: `${msgRecords} A ação não pode ser desfeita. Confirmar?`,
|
||
icon: 'pi pi-exclamation-triangle',
|
||
acceptLabel: 'Sim, confirmar',
|
||
rejectLabel: 'Cancelar',
|
||
acceptClass: 'p-button-danger',
|
||
accept: async () => {
|
||
eventoBusy.value = true;
|
||
try {
|
||
// 1) Remove cobranças vinculadas (não-pagas)
|
||
if (recordsCount > 0) {
|
||
const { error: recErr } = await supabase.from('financial_records').delete().eq('agenda_evento_id', ev.id);
|
||
if (recErr) throw recErr;
|
||
}
|
||
if (isMaterializedOccurrence) {
|
||
// Cria exception cancel_session + DELETE da row (some da agenda)
|
||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
|
||
if (origDate) {
|
||
// upsert pra ser idempotente: se já existe exception
|
||
// pra (recurrence_id, original_date), sobrescreve.
|
||
const { error: exErr } = await supabase.from('recurrence_exceptions').upsert({
|
||
recurrence_id: ev.recurrence_id || ev.serie_id,
|
||
tenant_id: tenantId,
|
||
original_date: origDate,
|
||
type: 'cancel_session',
|
||
reason: 'Cancelado pelo terapeuta'
|
||
}, { onConflict: 'recurrence_id,original_date' });
|
||
if (exErr) console.warn('[Excluir] exception upsert falhou:', exErr?.message);
|
||
}
|
||
}
|
||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
|
||
if (error) throw error;
|
||
const detail = recordsCount > 0 ? `Sessão e ${recordsCount} cobrança(s) removida(s).` : 'Sessão removida.';
|
||
toast.add({ severity: 'success', summary: isMaterializedOccurrence ? 'Ocorrência cancelada' : 'Excluída', detail, life: 3000 });
|
||
M.refetch();
|
||
refetchEventosHoje();
|
||
fecharEvento();
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
|
||
} finally {
|
||
eventoBusy.value = false;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Excluir SÉRIE INTEIRA — hard delete (escolha do user em 19/05).
|
||
// Deleta recurrence_rules (CASCADE leva exceptions + recurrence_rule_services),
|
||
// agenda_eventos materializados (linha por linha pra disparar handlers/triggers),
|
||
// e financial_records pendentes ligados. Bloqueia se houver QUALQUER record pago
|
||
// (precisa estornar pelo Financeiro primeiro).
|
||
async function onDeleteSeries() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev || eventoBusy.value) return;
|
||
const ruleId = ev.recurrence_id || ev.serie_id;
|
||
if (!ruleId) {
|
||
toast.add({ severity: 'warn', summary: 'Sem série', detail: 'Este evento não pertence a uma recorrência.', life: 3000 });
|
||
return;
|
||
}
|
||
|
||
// Conta eventos materializados + records vinculados pra montar mensagem
|
||
// e detectar paid blockers ANTES de confirmar.
|
||
let materializedCount = 0;
|
||
let recordsPending = 0;
|
||
let hasPaid = false;
|
||
try {
|
||
const { data: evts } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id')
|
||
.eq('recurrence_id', ruleId);
|
||
materializedCount = (evts || []).length;
|
||
const evtIds = (evts || []).map((e) => e.id);
|
||
if (evtIds.length) {
|
||
const { data: recs } = await supabase
|
||
.from('financial_records')
|
||
.select('id, status')
|
||
.in('agenda_evento_id', evtIds)
|
||
.is('deleted_at', null);
|
||
for (const r of recs || []) {
|
||
if (r.status === 'paid') hasPaid = true;
|
||
else recordsPending++;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[onDeleteSeries] contagem falhou:', e?.message);
|
||
}
|
||
|
||
if (hasPaid) {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Série com pagamento confirmado',
|
||
detail: 'Uma ou mais sessões desta série têm cobrança paga. Estorne primeiro pelo Financeiro antes de excluir a série.',
|
||
life: 6000
|
||
});
|
||
return;
|
||
}
|
||
|
||
const msgParts = [];
|
||
msgParts.push(`Esta ação remove **toda a série de recorrência**:`);
|
||
if (materializedCount > 0) msgParts.push(`${materializedCount} sessão(ões) já materializada(s)`);
|
||
if (recordsPending > 0) msgParts.push(`${recordsPending} cobrança(s) pendente(s)`);
|
||
msgParts.push('e a própria regra. As ocorrências futuras param de aparecer na agenda. **A ação não pode ser desfeita.** Confirmar?');
|
||
|
||
confirm.require({
|
||
header: 'Excluir série inteira',
|
||
message: msgParts.join(' '),
|
||
icon: 'pi pi-exclamation-triangle',
|
||
acceptLabel: 'Sim, excluir série',
|
||
rejectLabel: 'Manter',
|
||
acceptClass: 'p-button-danger',
|
||
accept: async () => {
|
||
eventoBusy.value = true;
|
||
try {
|
||
// 1) Apaga financial_records pendentes vinculados a eventos
|
||
// materializados desta série
|
||
const { data: evts2 } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id')
|
||
.eq('recurrence_id', ruleId);
|
||
const evtIds = (evts2 || []).map((e) => e.id);
|
||
if (evtIds.length) {
|
||
const { error: recErr } = await supabase
|
||
.from('financial_records')
|
||
.delete()
|
||
.in('agenda_evento_id', evtIds);
|
||
if (recErr) throw recErr;
|
||
}
|
||
// 2) Apaga eventos materializados
|
||
if (evtIds.length) {
|
||
const { error: evErr } = await supabase
|
||
.from('agenda_eventos')
|
||
.delete()
|
||
.in('id', evtIds);
|
||
if (evErr) throw evErr;
|
||
}
|
||
// 3) Apaga a regra (CASCADE: exceptions + rule_services)
|
||
const { error: ruleErr } = await supabase
|
||
.from('recurrence_rules')
|
||
.delete()
|
||
.eq('id', ruleId);
|
||
if (ruleErr) throw ruleErr;
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Série excluída',
|
||
detail: `Regra + ${materializedCount} sessão(ões)${recordsPending > 0 ? ` + ${recordsPending} cobrança(s)` : ''} removida(s).`,
|
||
life: 3500
|
||
});
|
||
M.refetch();
|
||
refetchEventosHoje();
|
||
fecharEvento();
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir série.', life: 5000 });
|
||
} finally {
|
||
eventoBusy.value = false;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Atalho do popover: gera cobrança da sessão atual via gerarCobrancaManual
|
||
// sem precisar abrir o AgendaEventDialog. Mesmo RPC do botão "Gerar
|
||
// cobrança" do AgendaEventoFinanceiroPanel. Só vale pra sessão real
|
||
// (eventoSelecionado.id existe + não é virtual). Apos sucesso refetch
|
||
// pra badge $ aparecer (paymentState='pending').
|
||
async function onGerarCobrancaQuick() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev || eventoBusy.value) return;
|
||
if (!ev.id || ev.is_occurrence) {
|
||
toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Esta ocorrência ainda não está materializada.', life: 3500 });
|
||
return;
|
||
}
|
||
eventoBusy.value = true;
|
||
try {
|
||
// gerarCobrancaManual le evento.price; passamos a row crua via _raw
|
||
// que ja inclui price/insurance_value/billing_contract_id.
|
||
const eventoRaw = ev._raw || ev;
|
||
const result = await gerarCobrancaManual(eventoRaw);
|
||
if (!result.ok) throw new Error(result.error);
|
||
const valor = eventoRaw.price ?? eventoRaw.insurance_value ?? 0;
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Cobrança gerada',
|
||
detail: `R$ ${Number(valor).toFixed(2).replace('.', ',')} agendado para recebimento.`,
|
||
life: 3000
|
||
});
|
||
M.refetch();
|
||
refetchEventosHoje();
|
||
// Fecha o popover apos sucesso pra impedir click duplicado
|
||
// gerando outra fatura. User reabre se quiser ver estado novo.
|
||
fecharEvento();
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar cobrança.', life: 4000 });
|
||
} finally {
|
||
eventoBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function onWhatsapp() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.patient_id) {
|
||
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
|
||
return;
|
||
}
|
||
const patientId = String(ev.patient_id);
|
||
fecharEvento();
|
||
// openForPatient é async — busca thread existente ou cria stub.
|
||
// Se paciente não tem telefone (ou outro erro), o store seta `error`
|
||
// e mantém `isOpen=false` silenciosamente. Aguardamos pra dar feedback.
|
||
await conversationDrawerStore.openForPatient(patientId);
|
||
if (!conversationDrawerStore.isOpen) {
|
||
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
|
||
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
|
||
conversationDrawerStore.error = null;
|
||
}
|
||
}
|
||
|
||
// ── Pending agenda actions ────────────────────────────────────
|
||
// Eventos clicados no resumo (timeline) podem disparar actions que
|
||
// dependem do MelissaAgenda estar montado (openProntuario, setView).
|
||
// Como o resumo NÃO tem a Agenda montada, abrimos a seção e enfileiramos
|
||
// a action — quando `melissaAgendaRef` aparece, o watch dispara.
|
||
const _pendingAgendaAction = ref(null);
|
||
watch(melissaAgendaRef, (refValue) => {
|
||
if (!refValue || !_pendingAgendaAction.value) return;
|
||
const action = _pendingAgendaAction.value;
|
||
_pendingAgendaAction.value = null;
|
||
try { action(refValue); } catch (e) { /* eslint-disable-next-line no-console */ console.warn('[pending-agenda]', e); }
|
||
});
|
||
function _callOnAgenda(action) {
|
||
if (melissaAgendaRef.value) {
|
||
try { action(melissaAgendaRef.value); } catch (e) { /* eslint-disable-next-line no-console */ console.warn('[agenda-action]', e); }
|
||
return;
|
||
}
|
||
_pendingAgendaAction.value = action;
|
||
if (secaoAberta.value !== 'agenda') abrirSecao('agenda');
|
||
}
|
||
|
||
function onAbrirProntuario() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.patient_id) {
|
||
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
|
||
return;
|
||
}
|
||
const p = pacientesReais.value.find((x) => String(x.id) === String(ev.patient_id));
|
||
if (!p) {
|
||
toast.add({ severity: 'warn', summary: 'Paciente não encontrado na lista', life: 2200 });
|
||
return;
|
||
}
|
||
fecharEvento();
|
||
_callOnAgenda((agenda) => agenda.openProntuario?.(p));
|
||
}
|
||
|
||
function onHistoricoSessoes() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.patient_id) {
|
||
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
|
||
return;
|
||
}
|
||
const patientId = ev.patient_id;
|
||
fecharEvento();
|
||
// Abre a Agenda (se não estiver) e dispara o overlay "Todas as sessões"
|
||
// filtrado pelo paciente — mesmo fluxo do botão Sessões em .ma-dock-actions.
|
||
_callOnAgenda((agenda) => agenda.openSessoesPaciente?.(patientId));
|
||
}
|
||
|
||
// Editar cadastro do paciente vinculado à sessão. Difere de onEditEvento
|
||
// (que abre o AgendaEventDialog pra mexer na sessão em si). Reusa o
|
||
// PatientCadastroDialog já montado dentro do MelissaAgenda via método
|
||
// exposto openEditPatient.
|
||
function onEditPaciente() {
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.patient_id) {
|
||
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
|
||
return;
|
||
}
|
||
const patientId = ev.patient_id;
|
||
fecharEvento();
|
||
_callOnAgenda((agenda) => agenda.openEditPatient?.(patientId));
|
||
}
|
||
|
||
async function onEditEvento() {
|
||
// Abre AgendaEventDialog completo via composable. Eventos vindos do
|
||
// resumo (useMelissaEventosHoje) não têm `_raw` — buscamos o row
|
||
// completo agora pra alimentar o dialog (campos de recorrência,
|
||
// financeiro, terapeuta_id, etc.).
|
||
const ev = eventoSelecionado.value;
|
||
if (!ev?.id || eventoBusy.value) return;
|
||
// Se já tiver _raw (caso futuro: vier da Agenda), usa direto.
|
||
if (ev._raw) {
|
||
const raw = ev._raw;
|
||
fecharEvento();
|
||
M.onEditEvento(raw);
|
||
return;
|
||
}
|
||
eventoBusy.value = true;
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('*')
|
||
.eq('id', ev.id)
|
||
.maybeSingle();
|
||
if (error) throw error;
|
||
if (!data) {
|
||
toast.add({ severity: 'warn', summary: 'Evento não encontrado', life: 2200 });
|
||
return;
|
||
}
|
||
fecharEvento();
|
||
M.onEditEvento(data);
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Falha ao abrir editor', detail: e?.message || 'Erro inesperado', life: 4000 });
|
||
} finally {
|
||
eventoBusy.value = false;
|
||
}
|
||
}
|
||
|
||
function onRemarcar() {
|
||
// MVP: muda status pra 'remarcar'. Reagendar de fato (mover horário)
|
||
// sai daqui pra dentro do AgendaEventDialog, via "Editar".
|
||
updateEventoStatus('remarcar', 'Marcada pra remarcar');
|
||
}
|
||
|
||
// Filtro da timeline por tipo (clicado no resumo do hero, lido pela
|
||
// timeline e pelo hero pra destacar chip ativo). Permanece aqui no
|
||
// pai porque eh state compartilhado entre dois filhos.
|
||
const filtroTipo = ref(null);
|
||
function toggleFiltro(tipo) {
|
||
filtroTipo.value = filtroTipo.value === tipo ? null : tipo;
|
||
}
|
||
function limparFiltro() {
|
||
filtroTipo.value = null;
|
||
}
|
||
|
||
// Helper de formatacao de hora — usado pelo card "proximo paciente"
|
||
// abaixo. A timeline tem sua propria copia interna.
|
||
function fmtHora(h) {
|
||
const horas = Math.floor(h);
|
||
const mins = Math.round((h - horas) * 60);
|
||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Cards do resumo — proximo-paciente derivado de eventosHojeReais.
|
||
// ───────────────────────────────────────────────────────────────
|
||
function _iniciaisDe(nome) {
|
||
const partes = String(nome || '').trim().split(/\s+/).filter(Boolean);
|
||
if (!partes.length) return '?';
|
||
if (partes.length === 1) return partes[0].slice(0, 2).toUpperCase();
|
||
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
|
||
}
|
||
function _fmtTempoAte(min) {
|
||
if (min < 1) return 'agora';
|
||
if (min < 60) return `em ${min} min`;
|
||
const h = Math.floor(min / 60);
|
||
const m = min % 60;
|
||
if (m === 0) return `em ${h}h`;
|
||
return `em ${h}h${String(m).padStart(2, '0')}`;
|
||
}
|
||
const proximoPaciente = computed(() => {
|
||
const d = now.value;
|
||
const hNow = d.getHours() + d.getMinutes() / 60;
|
||
const futuros = (eventosHojeReais.value || [])
|
||
.filter((ev) => {
|
||
// Só atendimentos (tipo sessao). Bloqueios e outros não contam.
|
||
if (ev.tipo && ev.tipo !== 'sessao') return false;
|
||
const s = String(ev.status || '').toLowerCase();
|
||
if (s === 'cancelado' || s === 'cancelada' || s === 'faltou' || s === 'realizado' || s === 'realizada') return false;
|
||
// Inclui em-curso (endH > hNow) — paciente atual conta como "próximo".
|
||
return ev.endH > hNow;
|
||
})
|
||
.sort((a, b) => a.startH - b.startH);
|
||
const ev = futuros[0];
|
||
if (!ev) return null;
|
||
const emMin = Math.max(0, Math.round((ev.startH - hNow) * 60));
|
||
const emCurso = hNow >= ev.startH && hNow < ev.endH;
|
||
const nome = ev.pacienteNome || ev.label || ev.titulo || '—';
|
||
return {
|
||
ev,
|
||
nome,
|
||
iniciais: _iniciaisDe(nome),
|
||
horario: fmtHora(ev.startH),
|
||
emMin,
|
||
emCurso,
|
||
statusLabel: emCurso ? 'em curso' : _fmtTempoAte(emMin)
|
||
};
|
||
});
|
||
|
||
// WhatsApp: total de unread + preview do thread mais recente.
|
||
// Lê de `conversation_threads` (mesma view do drawer/kanban).
|
||
const { summary: whatsappPendente } = useMelissaWhatsapp();
|
||
|
||
// Notificações + ajuda + plan switcher (DEV) — triggers do "topbar" Melissa.
|
||
const notificationStore = useNotificationStore();
|
||
useNotifications(); // hook side-effect: realtime + toasts de system_alerts
|
||
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda();
|
||
function toggleAjuda() {
|
||
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer();
|
||
}
|
||
const {
|
||
planBtn,
|
||
planMenu,
|
||
planMenuModel,
|
||
planMenuLoading,
|
||
trocandoPlano,
|
||
showPlanDevMenu,
|
||
openPlanMenu
|
||
} = useTopbarPlanMenu();
|
||
|
||
// Recebíveis derivados de agenda_eventos.{price,billed}: aproximação MVP.
|
||
// `billed=true` é o flag de "marcado como pago/cobrado" no agenda — não
|
||
// é a fonte de verdade financeira (essa é financial_records.status='paid'),
|
||
// mas é coerente com o resto da timeline e suficiente pro card de resumo.
|
||
// Refinar depois se quiser fidelidade real.
|
||
const recebiveis = computed(() => {
|
||
const eventos = (eventosHojeReais.value || []).filter((ev) => {
|
||
if (ev.tipo && ev.tipo !== 'sessao') return false;
|
||
const s = String(ev.status || '').toLowerCase();
|
||
if (s === 'cancelado' || s === 'cancelada' || s === 'faltou') return false;
|
||
return true;
|
||
});
|
||
let previsto = 0;
|
||
let recebido = 0;
|
||
let sessoesPagas = 0;
|
||
for (const ev of eventos) {
|
||
const p = Number(ev.price || 0);
|
||
previsto += p;
|
||
if (ev.billed) {
|
||
recebido += p;
|
||
sessoesPagas += 1;
|
||
}
|
||
}
|
||
return {
|
||
previsto,
|
||
recebido,
|
||
sessoes: eventos.length,
|
||
sessoesPagas,
|
||
hasPrice: previsto > 0
|
||
};
|
||
});
|
||
const recebidoPct = computed(() => {
|
||
const r = recebiveis.value;
|
||
return r.previsto > 0 ? Math.round((r.recebido / r.previsto) * 100) : 0;
|
||
});
|
||
|
||
// Copilot: backend real (LLM/insights) ainda não existe. Mantido no
|
||
// catálogo como `implementado: false` pra renderizar via branch placeholder
|
||
// "Em breve" — sem mock que finja funcionalidade.
|
||
|
||
function brl(v) {
|
||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Cronômetro de sessão (componente externo)
|
||
// ───────────────────────────────────────────────────────────────
|
||
// TODO: vir das configurações do tenant (página /configuracoes já existe)
|
||
const CONFIG_DURACAO_MIN = 50;
|
||
|
||
// pacientesMock removido — agora usa `pacientesReais` (composable Supabase).
|
||
// Mantido como fallback comentado pra debug rápido em dev se precisar:
|
||
// const pacientesMock = [{ id: 'p1', nome: 'Marina Silva' }, ...];
|
||
|
||
const cronoRef = ref(null);
|
||
const cronoVisible = ref(false); // sincronizado via @visible-change
|
||
|
||
// Preferencia de toque + funcao de teste (composable). cronoRef/abrir/fechar
|
||
// continuam aqui — sao instance state do <MelissaCronometro>, nao da pref.
|
||
const { toqueTermino, testarToque } = useMelissaToques('sino');
|
||
|
||
function abrirCronometro() {
|
||
cronoRef.value?.abrir();
|
||
}
|
||
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
|
||
// nas refs — watchers de persistência (localStorage + DB) ficam neste
|
||
// arquivo, mais abaixo.
|
||
provide('melissaSettings', {
|
||
layoutConfig,
|
||
isDarkTheme,
|
||
activeSurface,
|
||
PRIMARY_COLORS,
|
||
SURFACES,
|
||
PRESETS,
|
||
setPrimary,
|
||
setSurface,
|
||
setPreset,
|
||
setDark,
|
||
// bg + opacidades + handler do input <type="file">
|
||
bgUrl,
|
||
overlayOpacity,
|
||
bgImageOpacity,
|
||
onFileChange,
|
||
clearBg,
|
||
// relógio
|
||
use24h,
|
||
// cronômetro
|
||
toqueTermino,
|
||
testarToque,
|
||
// tema (bundle wallpaper + cores) — Freud/Klein/Jung
|
||
themeName,
|
||
setThemeName,
|
||
// fundo nos textos do hero (relogio, saudacao, resumo)
|
||
textBgEnabled,
|
||
setTextBgEnabled
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Persistência de prefs UI
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Camadas:
|
||
// 1. localStorage — cache rápido pra evitar flash no boot e hold do bgUrl
|
||
// (data URL pesada não vai pro DB)
|
||
// 2. user_settings.melissa_prefs (jsonb) — fonte de verdade pras prefs
|
||
// pequenas (toque, opacidades, formato hora, cards). Sobrevive a
|
||
// troca de navegador/dispositivo.
|
||
//
|
||
// Fluxo: onMounted → load localStorage (paint imediato) → load DB (sobrescreve
|
||
// se houver). Watch das refs → save localStorage (sync) + save DB (debounced).
|
||
|
||
// Sanitiza um payload de prefs (DB ou localStorage — ambos são input externo)
|
||
function applyPrefsPayload(prefs) {
|
||
if (!prefs || typeof prefs !== 'object') return;
|
||
|
||
if (typeof prefs.toqueTermino === 'string' && TOQUE_IDS.has(prefs.toqueTermino)) {
|
||
toqueTermino.value = prefs.toqueTermino;
|
||
}
|
||
const op = Number(prefs.overlayOpacity);
|
||
if (Number.isFinite(op) && op >= 0 && op <= 0.8) {
|
||
overlayOpacity.value = op;
|
||
}
|
||
const bo = Number(prefs.bgImageOpacity);
|
||
if (Number.isFinite(bo) && bo >= 0.01 && bo <= 1) {
|
||
bgImageOpacity.value = bo;
|
||
}
|
||
if (typeof prefs.use24h === 'boolean') {
|
||
use24h.value = prefs.use24h;
|
||
}
|
||
if (Array.isArray(prefs.cardsAtivos)) {
|
||
const valid = prefs.cardsAtivos.filter((id) => typeof id === 'string' && CARD_IDS.has(id));
|
||
if (valid.length > 0) cardsAtivos.value = valid;
|
||
}
|
||
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() {
|
||
return {
|
||
toqueTermino: toqueTermino.value,
|
||
overlayOpacity: overlayOpacity.value,
|
||
bgImageOpacity: bgImageOpacity.value,
|
||
use24h: use24h.value,
|
||
cardsAtivos: cardsAtivos.value,
|
||
cardsLayout: cardsLayout.value,
|
||
themeName: themeName.value,
|
||
textBgEnabled: textBgEnabled.value
|
||
};
|
||
}
|
||
|
||
function saveLayoutPrefs() {
|
||
// localStorage inclui bgUrl (data URL); DB não — bgUrl fica local
|
||
const prefs = { ...currentPrefsSnapshot(), bgUrl: bgUrl.value || '' };
|
||
try {
|
||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(prefs));
|
||
} catch {
|
||
// Quota provavelmente estourou por causa do bgUrl (data URL grande).
|
||
// Tenta sem ele — as outras prefs são minúsculas.
|
||
try {
|
||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ ...prefs, bgUrl: '' }));
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
function loadLocalPrefs() {
|
||
let raw;
|
||
try { raw = localStorage.getItem(LAYOUT_STORAGE_KEY); } catch { return; }
|
||
if (!raw) return;
|
||
|
||
let prefs;
|
||
try { prefs = JSON.parse(raw); } catch { return; }
|
||
if (!prefs || typeof prefs !== 'object') return;
|
||
|
||
applyPrefsPayload(prefs);
|
||
|
||
// bgUrl: aceita só data URL de imagem (evita injeção de URL externa via storage)
|
||
if (typeof prefs.bgUrl === 'string' && prefs.bgUrl.startsWith('data:image/')) {
|
||
bgUrl.value = prefs.bgUrl;
|
||
}
|
||
}
|
||
|
||
// ── DB sync ────────────────────────────────────────────────────
|
||
let dbSaveTimer = null;
|
||
let dbReady = false; // só salva no DB depois do load inicial (evita sobrescrever c/ defaults)
|
||
|
||
async function loadDbPrefs() {
|
||
try {
|
||
const { data: u } = await supabase.auth.getUser();
|
||
const uid = u?.user?.id;
|
||
if (!uid) return;
|
||
|
||
const { data, error } = await supabase
|
||
.from('user_settings')
|
||
.select('melissa_prefs')
|
||
.eq('user_id', uid)
|
||
.maybeSingle();
|
||
if (error) return;
|
||
if (data?.melissa_prefs && typeof data.melissa_prefs === 'object') {
|
||
applyPrefsPayload(data.melissa_prefs);
|
||
}
|
||
} catch {
|
||
// silencioso — falha de rede/auth não pode quebrar a UI
|
||
} finally {
|
||
dbReady = true;
|
||
}
|
||
}
|
||
|
||
async function saveDbPrefs() {
|
||
if (!dbReady) return;
|
||
try {
|
||
const { data: u } = await supabase.auth.getUser();
|
||
const uid = u?.user?.id;
|
||
if (!uid) return;
|
||
|
||
await supabase
|
||
.from('user_settings')
|
||
.upsert(
|
||
{ user_id: uid, melissa_prefs: currentPrefsSnapshot(), updated_at: new Date().toISOString() },
|
||
{ onConflict: 'user_id' }
|
||
);
|
||
} catch {
|
||
// silencioso — pref save não-crítico; localStorage segura o estado até voltar
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Salva em qualquer mudança das prefs (deep no array de cardsAtivos pra pegar splice/push)
|
||
watch(
|
||
[toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout, themeName, textBgEnabled],
|
||
() => {
|
||
saveLayoutPrefs();
|
||
queueDbSave();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// Carrega antes do paint (use onMounted; o flash inicial é aceitável)
|
||
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
|
||
// componente MelissaTimelineHoje (agora self-contained).
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Workspace overlay
|
||
// ───────────────────────────────────────────────────────────────
|
||
const workspaceOpen = ref(false);
|
||
|
||
function openWorkspace() {
|
||
workspaceOpen.value = true;
|
||
}
|
||
function closeWorkspace() {
|
||
workspaceOpen.value = false;
|
||
}
|
||
function toggleWorkspace() {
|
||
workspaceOpen.value ? closeWorkspace() : openWorkspace();
|
||
}
|
||
|
||
// Resumo recebe blur quando qualquer overlay tá aberto (cronômetro minimizado NÃO conta)
|
||
const summaryDimmed = computed(
|
||
() => workspaceOpen.value
|
||
|| cronoVisible.value
|
||
|| !!eventoSelecionado.value
|
||
|| !!secaoAberta.value
|
||
|| centralOpen.value
|
||
);
|
||
|
||
function onKeydown(e) {
|
||
// Ctrl+\ ou ⌘+\ → abre/fecha workspace (psi-btn)
|
||
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
|
||
e.preventDefault();
|
||
toggleWorkspace();
|
||
return;
|
||
}
|
||
|
||
if (e.key !== 'Escape') return;
|
||
// Bail-out: se há um overlay PrimeVue (Dialog/Drawer) aberto, deixa
|
||
// o componente cuidar do ESC pelo seu próprio closeOnEscape. Sem
|
||
// este guard, o ESC fechava o overlay E uma camada do cascade —
|
||
// o usuário via "duas janelas fechando" (drawer WhatsApp + agenda,
|
||
// AgendaEventDialog + evento panel, ConfirmDialog + workspace, etc).
|
||
if (document.querySelector('.p-dialog-mask, .p-drawer-mask')) return;
|
||
|
||
// Cascata top-down do z-order — ESC fecha SOMENTE a camada do topo.
|
||
// Ordem (mais sobreposto → menos): central modal > evento panel >
|
||
// cronômetro > seção (agenda/pacientes/etc) > workspace > settings.
|
||
if (centralOpen.value) centralOpen.value = false;
|
||
else if (eventoSelecionado.value) fecharEvento();
|
||
else if (cronoVisible.value) fecharCronometro();
|
||
else if (secaoAberta.value) fecharSecao();
|
||
else if (workspaceOpen.value) closeWorkspace();
|
||
else if (settingsOpen.value) settingsOpen.value = false;
|
||
}
|
||
|
||
// modulos[] removido — MelissaMenu agora tem catálogo próprio (CATEGORIAS)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="win11-root" :class="{ 'win11-has-photo': !!bgUrl }" :style="defaultBgStyle">
|
||
<!-- Camada da foto custom (acima do gradiente, abaixo do dim).
|
||
Opacidade controlada pelo slider — permite blend com o gradiente
|
||
default que vive no .win11-root. -->
|
||
<div v-if="bgUrl" class="win11-photo" :style="photoStyle" />
|
||
|
||
<!-- Overlay escurecedor (controlado pelo slider) -->
|
||
<div class="win11-dim" :style="{ backgroundColor: `rgba(var(--m-dim-rgb), ${overlayOpacity})` }" />
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- 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
|
||
AppLayout. Drawer de notificações também é montado abaixo
|
||
(AjudaDrawer já é global no App.vue). -->
|
||
<div class="absolute top-5 right-5 z-30 flex items-center gap-2">
|
||
<!-- Plan switcher DEV (só aparece em dev / com flag) -->
|
||
<button
|
||
v-if="showPlanDevMenu"
|
||
ref="planBtn"
|
||
class="glass-btn w-10 h-10 grid place-items-center"
|
||
:disabled="planMenuLoading || trocandoPlano"
|
||
title="Plano (DEV)"
|
||
@click="openPlanMenu"
|
||
>
|
||
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
|
||
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
|
||
</button>
|
||
|
||
<!-- Notificações -->
|
||
<button
|
||
class="glass-btn w-10 h-10 grid place-items-center relative"
|
||
title="Notificações"
|
||
@click="notificationStore.drawerOpen = true"
|
||
>
|
||
<i class="pi pi-bell text-white/90 text-base" />
|
||
<span
|
||
v-if="notificationStore.unreadCount > 0"
|
||
class="m-topbar-badge"
|
||
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
|
||
</button>
|
||
|
||
<!-- Ajuda -->
|
||
<button
|
||
class="glass-btn w-10 h-10 grid place-items-center"
|
||
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
|
||
:title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
|
||
@click="toggleAjuda"
|
||
>
|
||
<i class="pi pi-question-circle text-white/90 text-base" />
|
||
</button>
|
||
|
||
<!-- Cog (settings popover) — existente -->
|
||
<button
|
||
class="glass-btn w-10 h-10 grid place-items-center"
|
||
title="Personalizar"
|
||
@click="settingsOpen = !settingsOpen"
|
||
>
|
||
<i class="pi pi-cog text-white/90 text-base" />
|
||
</button>
|
||
|
||
<Transition name="settings-pop">
|
||
<MelissaSettingsPanel
|
||
v-if="settingsOpen"
|
||
@close="settingsOpen = false"
|
||
/>
|
||
</Transition>
|
||
</div>
|
||
|
||
<!-- Conteúdo central -->
|
||
<div class="win11-summary__inner">
|
||
<!-- Bloco hero: relógio + data + saudação + resumo do dia -->
|
||
<MelissaHeroClock
|
||
:hora="horaFormatada"
|
||
:data-extenso="dataExtenso"
|
||
:saudacao="saudacao"
|
||
:resumo-partes="resumoPartes"
|
||
:filtro-tipo="filtroTipo"
|
||
:text-bg="textBgEnabled"
|
||
@cronometro="abrirCronometro"
|
||
@toggle-filtro="toggleFiltro"
|
||
/>
|
||
|
||
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
|
||
<MelissaBusca
|
||
class="mt-8"
|
||
:pacientes="pacientesReais"
|
||
:eventos="eventosHojeReais"
|
||
@acao="abrirSecao"
|
||
@paciente="() => abrirSecao('pacientes')"
|
||
@evento="abrirEvento"
|
||
/>
|
||
|
||
<!-- Timeline horizontal + vertical (responsivo) -->
|
||
<MelissaTimelineHoje
|
||
:eventos="eventosHojeReais"
|
||
:now="now"
|
||
:work-rules="agendaWorkRules"
|
||
:agenda-settings="agendaSettings"
|
||
:feriados="agendaFeriados"
|
||
:filtro-tipo="filtroTipo"
|
||
@evento="abrirEvento"
|
||
@clear-filter="limparFiltro"
|
||
/>
|
||
|
||
<!-- Cards (catálogo + ativos + layout switchável) -->
|
||
<section
|
||
class="cards-shell mt-5 w-full mx-auto"
|
||
:class="{ 'is-wrap': cardsLayout === 'duas-linhas' }"
|
||
>
|
||
<div
|
||
class="cards-row"
|
||
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
|
||
>
|
||
<template v-for="cardId in cardsAtivos" :key="cardId">
|
||
<!-- Próximo paciente -->
|
||
<MelissaCard
|
||
v-if="cardId === 'proximo-paciente'"
|
||
icon="pi pi-user"
|
||
icon-color="text-emerald-300"
|
||
title="Próximo paciente"
|
||
:action-title="proximoPaciente ? 'Abrir sessão' : 'Abrir Pacientes'"
|
||
@open="proximoPaciente ? abrirEvento(proximoPaciente.ev) : abrirSecao('pacientes')"
|
||
>
|
||
<div v-if="proximoPaciente" class="flex items-center gap-3">
|
||
<div
|
||
class="w-12 h-12 rounded-full grid place-items-center text-white font-semibold border"
|
||
:class="proximoPaciente.emCurso
|
||
? 'bg-emerald-500/40 border-emerald-300/60 ring-2 ring-emerald-400/40 ring-offset-0 animate-pulse'
|
||
: 'bg-emerald-500/30 border-emerald-300/40'"
|
||
>
|
||
{{ proximoPaciente.iniciais }}
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-white font-medium truncate">{{ proximoPaciente.nome }}</div>
|
||
<div class="text-white/60 text-xs">
|
||
{{ proximoPaciente.horario }} · {{ proximoPaciente.statusLabel }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="flex items-center gap-3">
|
||
<div class="w-12 h-12 rounded-full bg-white/5 border border-white/10 grid place-items-center text-white/40">
|
||
<i class="pi pi-check text-base" />
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-white/75 text-sm font-medium">Sem atendimentos pendentes</div>
|
||
<div class="text-white/45 text-xs">Tudo certo por hoje</div>
|
||
</div>
|
||
</div>
|
||
</MelissaCard>
|
||
|
||
<!-- WhatsApp -->
|
||
<MelissaCard
|
||
v-else-if="cardId === 'whatsapp'"
|
||
icon="pi pi-whatsapp"
|
||
icon-color="text-green-300"
|
||
title="WhatsApp"
|
||
:badge="whatsappPendente.count || null"
|
||
action-title="Abrir WhatsApp"
|
||
@open="abrirSecao('conversas')"
|
||
>
|
||
<template v-if="whatsappPendente.count > 0">
|
||
<div class="text-white/85 text-xs italic line-clamp-2 leading-relaxed">
|
||
{{ whatsappPendente.ultimaMsg || '(mensagem sem texto)' }}
|
||
</div>
|
||
<div class="text-white/50 text-[0.7rem] mt-1">— {{ whatsappPendente.ultimoNome }}</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="text-white/75 text-sm font-medium">Caixa de entrada limpa</div>
|
||
<div class="text-white/45 text-[0.7rem] mt-1">Sem mensagens não lidas.</div>
|
||
</template>
|
||
</MelissaCard>
|
||
|
||
<!-- Recebíveis -->
|
||
<MelissaCard
|
||
v-else-if="cardId === 'recebiveis'"
|
||
icon="pi pi-wallet"
|
||
icon-color="text-amber-300"
|
||
title="Recebíveis hoje"
|
||
action-title="Abrir Financeiro"
|
||
@open="abrirSecao('financeiro')"
|
||
>
|
||
<template v-if="recebiveis.hasPrice">
|
||
<div class="flex items-baseline gap-1.5">
|
||
<span class="text-white text-xl font-medium">{{ brl(recebiveis.recebido) }}</span>
|
||
<span class="text-white/50 text-xs">/ {{ brl(recebiveis.previsto) }}</span>
|
||
</div>
|
||
<div class="h-1.5 rounded-full bg-white/10 mt-2 overflow-hidden">
|
||
<div
|
||
class="h-full bg-gradient-to-r from-amber-400 to-amber-300 transition-all"
|
||
:style="{ width: `${recebidoPct}%` }"
|
||
/>
|
||
</div>
|
||
<div class="text-white/55 text-[0.7rem] mt-1.5">
|
||
{{ recebiveis.sessoesPagas }}/{{ recebiveis.sessoes }} sessões pagas
|
||
</div>
|
||
</template>
|
||
<template v-else-if="recebiveis.sessoes > 0">
|
||
<div class="text-white/75 text-sm font-medium">
|
||
{{ recebiveis.sessoes }} {{ recebiveis.sessoes === 1 ? 'sessão' : 'sessões' }} sem valor
|
||
</div>
|
||
<div class="text-white/45 text-[0.7rem] mt-1">
|
||
Defina o preço nas sessões pra acompanhar.
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="text-white/75 text-sm font-medium">Sem cobranças hoje</div>
|
||
<div class="text-white/45 text-[0.7rem] mt-1">Nenhuma sessão agendada.</div>
|
||
</template>
|
||
</MelissaCard>
|
||
|
||
<!-- Card "em breve" — placeholder pra ids do catálogo ainda não
|
||
implementados (inclui Copilot enquanto não há backend de IA) -->
|
||
<MelissaCard
|
||
v-else
|
||
:icon="getCatalogItem(cardId)?.icon || 'pi pi-clock'"
|
||
icon-color="text-white/40"
|
||
:title="getCatalogItem(cardId)?.label || cardId"
|
||
action-title="Em breve"
|
||
@open="abrirSecao('configuracoes')"
|
||
>
|
||
<div class="text-white/40 text-xs italic">Em breve</div>
|
||
</MelissaCard>
|
||
</template>
|
||
|
||
<!-- Em wrap: "+" segue o fluxo, ao final dos cards, com altura
|
||
espelhada (flex align-items: stretch faz isso natural) -->
|
||
<MelissaCard
|
||
v-if="cardsLayout === 'duas-linhas'"
|
||
variant="add"
|
||
action-title="Personalizar cards"
|
||
@add="centralOpen = true"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Em linha única: "+" fora do scroll, sempre visível à direita -->
|
||
<MelissaCard
|
||
v-if="cardsLayout === 'linha-unica'"
|
||
variant="add"
|
||
action-title="Personalizar cards"
|
||
@add="centralOpen = true"
|
||
/>
|
||
</section>
|
||
</div>
|
||
|
||
</div>
|
||
<!-- /win11-summary fecha aqui -->
|
||
|
||
<!-- Veu de blur: 1 layer unica com backdrop-filter por cima do resumo,
|
||
em vez de aplicar filter:blur na arvore inteira do .win11-summary
|
||
(5+ glass-panels com backdrop-filter proprio = layer explosion).
|
||
Custo: 1 backdrop pass por frame, contra N×N. opacity-only
|
||
transition + contain:strict isola repaints. -->
|
||
<div class="win11-blur-veil" :class="{ 'is-active': summaryDimmed }" aria-hidden="true" />
|
||
|
||
<!-- Botão único ψ — FORA da summary (não recebe blur/pointer-none
|
||
quando overlays estão abertos; permanece sempre clicável).
|
||
Continua position:absolute (não é filho do dock), mas vive na
|
||
mesma faixa visual da .melissa-dock (ambos no bottom). -->
|
||
<button
|
||
class="psi-btn"
|
||
:title="workspaceOpen ? 'Fechar workspace (Ctrl + \\)' : 'Abrir workspace (Ctrl + \\)'"
|
||
@click="toggleWorkspace"
|
||
>
|
||
<span class="psi-glyph">ψ</span>
|
||
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
|
||
</button>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- DOCK (taskbar Win11-style sem chrome) — receptáculo pra -->
|
||
<!-- elementos minimizados (cronômetro, futuros) + atalhos -->
|
||
<!-- pinned (Agenda, WhatsApp). Transparent, só os items são -->
|
||
<!-- clicáveis. ψ vive ao lado (absolute, bottom-left). -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<div class="melissa-dock">
|
||
<!-- Pinned: atalhos diretos pras seções mais usadas.
|
||
Tamanho menor que ψ (44px vs 56px) + cantos arredondados
|
||
mas não full-circle, pra hierarquia visual ficar óbvia. -->
|
||
<button
|
||
type="button"
|
||
class="dock-pin"
|
||
v-tooltip.top="'Agenda'"
|
||
:class="{ 'dock-pin--active': secaoAberta === 'agenda' }"
|
||
@click="abrirSecao('agenda')"
|
||
>
|
||
<i class="pi pi-calendar" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="dock-pin"
|
||
v-tooltip.top="'WhatsApp'"
|
||
:class="{ 'dock-pin--active': secaoAberta === 'conversas' }"
|
||
@click="abrirSecao('conversas')"
|
||
>
|
||
<i class="pi pi-whatsapp" />
|
||
<span
|
||
v-if="whatsappPendente.count > 0"
|
||
class="dock-pin__badge"
|
||
:title="`${whatsappPendente.count} mensagens não lidas`"
|
||
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
|
||
</button>
|
||
|
||
<!-- Divisor entre builtins e pins dinâmicos. Só aparece se
|
||
o user tem pelo menos 1 pin (fixo ou recente). -->
|
||
<div
|
||
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
|
||
class="dock-divider"
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
<!-- Pins fixados pelo user (max 4). Click direito → menu
|
||
desafixar/remover. Hover mostra subtle ring. -->
|
||
<button
|
||
v-for="slug in dockPins.pinned.value" :key="`p-${slug}`"
|
||
type="button"
|
||
class="dock-pin dock-pin--user"
|
||
:class="{ 'dock-pin--active': secaoAberta === slug }"
|
||
v-tooltip.top="pinMeta(slug).label + ' (fixado)'"
|
||
@click="abrirSecao(slug)"
|
||
@contextmenu="openPinContextMenu($event, slug)"
|
||
>
|
||
<i :class="pinMeta(slug).icon" />
|
||
<span class="dock-pin__pinned-mark" aria-hidden="true" />
|
||
</button>
|
||
|
||
<!-- Pins MRU (max 3) — empurrados pelas últimas seções abertas.
|
||
Visual mais leve (opacity menor) pra destacar dos fixos. -->
|
||
<button
|
||
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
|
||
type="button"
|
||
class="dock-pin dock-pin--recent"
|
||
:class="{ 'dock-pin--active': secaoAberta === slug }"
|
||
v-tooltip.top="pinMeta(slug).label + ' (recente — clique direito pra fixar)'"
|
||
@click="abrirSecao(slug)"
|
||
@contextmenu="openPinContextMenu($event, slug)"
|
||
>
|
||
<i :class="pinMeta(slug).icon" />
|
||
</button>
|
||
|
||
<!-- Menu de contexto dos pins dinâmicos (popup global) -->
|
||
<Menu ref="pinContextMenu" :model="pinContextMenuItems" :popup="true" />
|
||
</div>
|
||
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- MENU FLOAT (Win11-style Start) — abre via ψ ou Ctrl+\ -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<Transition name="menu-rise">
|
||
<MelissaMenu
|
||
v-if="workspaceOpen"
|
||
:secao-ativa="secaoAberta"
|
||
@select="abrirSecao"
|
||
@close="closeWorkspace"
|
||
@open-settings="onMenuOpenSettings"
|
||
/>
|
||
</Transition>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- DIALOG — Evento da timeline (sessão / supervisão / reunião) -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<Transition name="lift">
|
||
<MelissaEventoPanel
|
||
v-if="eventoSelecionado"
|
||
:evento="eventoSelecionado"
|
||
:busy="eventoBusy"
|
||
@close="fecharEvento"
|
||
@concluir="onConcluir"
|
||
@faltou="onFaltou"
|
||
@cancelar="onCancelar"
|
||
@remarcar="onRemarcar"
|
||
@edit-sessao="onEditEvento"
|
||
@delete-sessao="onDeleteEvento"
|
||
@delete-series="onDeleteSeries"
|
||
@gerar-cobranca="onGerarCobrancaQuick"
|
||
@ver-lancamentos="onVerLancamentos"
|
||
@antecipar-pagamento="onAnteciparPagamento"
|
||
@edit-paciente="onEditPaciente"
|
||
@abrir-prontuario="onAbrirProntuario"
|
||
@whatsapp="onWhatsapp"
|
||
@historico="onHistoricoSessoes"
|
||
/>
|
||
</Transition>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- CENTRAL DE CARDS — toggle de cards + modo de visualização -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<Transition name="lift">
|
||
<div
|
||
v-if="centralOpen"
|
||
class="central-layer"
|
||
@click.self="centralOpen = false"
|
||
>
|
||
<div class="central-panel">
|
||
<header class="flex items-start justify-between mb-5">
|
||
<div>
|
||
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Central de cards</div>
|
||
<div class="text-white text-xl font-light mt-1">Personalize seu resumo</div>
|
||
</div>
|
||
<button
|
||
class="glass-btn w-9 h-9 grid place-items-center shrink-0"
|
||
title="Fechar (Esc)"
|
||
@click="centralOpen = false"
|
||
>
|
||
<i class="pi pi-times text-white/90 text-sm" />
|
||
</button>
|
||
</header>
|
||
|
||
<!-- Modo de visualização -->
|
||
<div class="mb-5">
|
||
<div class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2">Visualização</div>
|
||
<div class="flex flex-col gap-2">
|
||
<label class="central-radio">
|
||
<input type="radio" v-model="cardsLayout" value="linha-unica" />
|
||
<span>Linha única <span class="text-white/40">(scroll horizontal)</span></span>
|
||
</label>
|
||
<label class="central-radio">
|
||
<input type="radio" v-model="cardsLayout" value="duas-linhas" />
|
||
<span>Duas linhas <span class="text-white/40">(quebra automática)</span></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cards disponíveis -->
|
||
<div>
|
||
<div class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2">
|
||
Cards disponíveis
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label
|
||
v-for="c in CARDS_CATALOG"
|
||
:key="c.id"
|
||
class="central-card-row"
|
||
:class="{ 'is-em-breve': !c.implementado }"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:checked="cardsAtivos.includes(c.id)"
|
||
@change="toggleCardAtivo(c.id)"
|
||
/>
|
||
<i :class="c.icon" class="text-white/70 w-4 text-center" />
|
||
<span class="flex-1">{{ c.label }}</span>
|
||
<span v-if="!c.implementado" class="text-[0.65rem] text-white/40">em breve</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- SESSÕES "promovidas" — páginas fullscreen dentro de Melissa -->
|
||
<!-- (não usam o dialog placeholder, viram páginas próprias) -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- Sem <Transition> wrapper aqui: o leave delay criava janela onde
|
||
os <Teleport> internos (.melissa-dock, #ma-mobile-drawer-target)
|
||
ficavam com placeholders órfãos no target compartilhado, e o
|
||
próximo patch lia .component anulado → "Cannot set properties of
|
||
null (setting '__vnode')". A animação de entrada vive como
|
||
keyframe em .ma-page / .mp-page nos próprios componentes. -->
|
||
<MelissaAgenda
|
||
v-if="layoutReady && secaoAberta === 'agenda'"
|
||
ref="melissaAgendaRef"
|
||
:pacientes="pacientesReais"
|
||
:pacientes-loading="pacientesLoading"
|
||
@select-evento="abrirEvento"
|
||
@close="fecharSecao"
|
||
@patient-created="refetchPacientes"
|
||
/>
|
||
|
||
<MelissaPacientes
|
||
v-if="layoutReady && secaoAberta === 'pacientes'"
|
||
@close="fecharSecao"
|
||
@patient-created="refetchPacientes"
|
||
@goto-agenda="abrirSecao('agenda')"
|
||
@goto-grupos="abrirSecao('grupos')"
|
||
@goto-tags="abrirSecao('tags')"
|
||
/>
|
||
|
||
<MelissaCompromissos
|
||
v-if="layoutReady && secaoAberta === 'compromissos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaRecorrencias
|
||
v-if="layoutReady && secaoAberta === 'recorrencias'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaConversas
|
||
v-if="layoutReady && secaoAberta === 'conversas'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaTags
|
||
v-if="layoutReady && secaoAberta === 'tags'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaGrupos
|
||
v-if="layoutReady && secaoAberta === 'grupos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaCadastrosRecebidos
|
||
v-if="layoutReady && secaoAberta === 'cadastros-recebidos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaAgendamentosRecebidos
|
||
v-if="layoutReady && secaoAberta === 'agendamentos-recebidos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaMedicos
|
||
v-if="layoutReady && secaoAberta === 'medicos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaLinkExterno
|
||
v-if="layoutReady && secaoAberta === 'link-externo'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaNotificacoes
|
||
v-if="layoutReady && secaoAberta === 'notificacoes'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaFinanceiro
|
||
v-if="layoutReady && secaoAberta === 'financeiro'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaFinanceiroLancamentos
|
||
v-if="layoutReady && secaoAberta === 'financeiro-lancamentos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaDocumentos
|
||
v-if="layoutReady && secaoAberta === 'documentos'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaDocumentosTemplates
|
||
v-if="layoutReady && secaoAberta === 'documentos-templates'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaRelatorios
|
||
v-if="layoutReady && secaoAberta === 'relatorios'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaPerfil
|
||
v-if="layoutReady && secaoAberta === 'perfil'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<!-- Pagina nativa do prontuario do paciente (Fase 8 wire-up).
|
||
ID vem via route.query.id (?id=xxx). MelissaPaciente cuida
|
||
internamente das acoes (close -> /melissa/pacientes; edit ->
|
||
/melissa/pacientes?edit=<id>; open-whatsapp -> conversationDrawerStore). -->
|
||
<MelissaPaciente
|
||
v-if="layoutReady && secaoAberta === 'paciente'"
|
||
:patient-id="String(route.query.id || '')"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaPlano
|
||
v-if="layoutReady && secaoAberta === 'plano'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaNegocio
|
||
v-if="layoutReady && secaoAberta === 'negocio'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaAlterarPlano
|
||
v-if="layoutReady && secaoAberta === 'alterar-plano'"
|
||
@close="fecharSecao"
|
||
@goto="abrirSecao"
|
||
/>
|
||
|
||
<MelissaSeguranca
|
||
v-if="layoutReady && secaoAberta === 'seguranca'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaBloqueios
|
||
v-if="layoutReady && secaoAberta === 'bloqueios'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaAgendador
|
||
v-if="layoutReady && secaoAberta === 'online-scheduling'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaAgendaConfig
|
||
v-if="layoutReady && secaoAberta === 'agenda-config'"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaPagamento
|
||
v-if="layoutReady && secaoAberta === 'pagamento'"
|
||
@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"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<MelissaEmbed
|
||
v-if="layoutReady && MELISSA_EMBED_KEYS.includes(secaoAberta)"
|
||
:secao-rota="secaoAberta"
|
||
@close="fecharSecao"
|
||
/>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- SEÇÃO — placeholder dialog pras sessões ainda não promovidas -->
|
||
<!-- (WhatsApp, Financeiro, Copilot...) -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<Transition name="lift">
|
||
<div
|
||
v-if="secaoAberta && !MELISSA_NON_CONFIG_SLUGS.has(secaoAberta) && !isMelissaConfigRoute(secaoAberta)"
|
||
class="secao-layer"
|
||
@click.self="fecharSecao"
|
||
>
|
||
<div class="secao-panel">
|
||
<header class="flex items-start justify-between mb-6">
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<div class="secao-icon">
|
||
<i :class="SECOES[secaoAberta].icon" class="text-xl text-white/85" />
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Seção</div>
|
||
<div class="text-white text-2xl font-light mt-1">
|
||
{{ SECOES[secaoAberta].label }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="glass-btn w-9 h-9 grid place-items-center shrink-0"
|
||
title="Fechar (Esc)"
|
||
@click="fecharSecao"
|
||
>
|
||
<i class="pi pi-times text-white/90 text-sm" />
|
||
</button>
|
||
</header>
|
||
|
||
<div class="secao-body">
|
||
<i :class="SECOES[secaoAberta].icon" class="secao-body-icon" />
|
||
<div class="text-white/75 text-base font-light max-w-md text-center">
|
||
{{ SECOES[secaoAberta].descricao }}
|
||
</div>
|
||
<div class="text-white/35 text-xs mt-3 text-center">
|
||
Placeholder — aqui virá o módulo de {{ SECOES[secaoAberta].label }}.
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="text-white/40 text-[0.7rem] text-center mt-4">
|
||
Pressione <kbd class="px-1.5 py-0.5 rounded bg-white/10 border border-white/15">Esc</kbd>
|
||
ou clique fora para voltar ao resumo
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- Cronômetro (componente externo, gerencia próprio estado) -->
|
||
<MelissaCronometro
|
||
ref="cronoRef"
|
||
:pacientes="pacientesReais"
|
||
:default-paciente-id="null"
|
||
:duracao-minutos="CONFIG_DURACAO_MIN"
|
||
:toque-termino="toqueTermino"
|
||
@visible-change="cronoVisible = $event"
|
||
@session-end="onCronometroSessionEnd"
|
||
/>
|
||
|
||
<!-- Drawer de conversas (WhatsApp): mesmo padrão do AppLayout.
|
||
Sem ele montado, conversationDrawerStore.openForPatient() ativa o
|
||
estado mas não tem componente reativo pra abrir. -->
|
||
<ConversationDrawer />
|
||
|
||
<!-- ConfirmDialog: usado pelos handlers da agenda (drag/resize -->
|
||
<!-- pede confirmação). Auto-resolvido via PrimeVueResolver. -->
|
||
<!-- -->
|
||
<!-- Slot #message override: persistMoveOrResize gera mensagens -->
|
||
<!-- com <strong> ao redor de datas/horários. v-html renderiza -->
|
||
<!-- HTML; nome do paciente já é escapado em useMelissaAgenda -->
|
||
<!-- (_esc) pra evitar XSS. -->
|
||
<ConfirmDialog>
|
||
<template #message="slotProps">
|
||
<span class="p-confirm-dialog-message" v-html="slotProps.message.message" />
|
||
</template>
|
||
</ConfirmDialog>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<!-- AgendaEventDialog — editor completo (CRUD + recorrência -->
|
||
<!-- + financeiro). Vive no nível do MelissaLayout pra cobrir -->
|
||
<!-- toda a tela e ser independente de qual seção está aberta. -->
|
||
<!-- Os handlers (M.onDialogSave/Delete/etc) e o estado vêm do -->
|
||
<!-- composable useMelissaAgenda. -->
|
||
<!-- ═══════════════════════════════════════════════════════ -->
|
||
<AgendaEventDialog
|
||
v-model="agendaDialogOpen"
|
||
:eventRow="agendaDialogEventRow"
|
||
:initialStartISO="agendaDialogStartISO"
|
||
:initialEndISO="agendaDialogEndISO"
|
||
:ownerId="agendaOwnerId"
|
||
:tenantId="agendaClinicTenantId"
|
||
:commitmentOptions="agendaCommitmentOptions"
|
||
:workRules="agendaWorkRules"
|
||
:blockedDates="[]"
|
||
:agendaSettings="agendaSettings"
|
||
:allEvents="agendaAllEvents"
|
||
:pausasSemanais="agendaSettings?.pausas_semanais || []"
|
||
:feriados="agendaFeriados"
|
||
:serieRefreshTick="agendaSerieRefreshTick"
|
||
:blockOverlapWarning="agendaDialogBlockOverlap"
|
||
newPatientRoute="/therapist/patients/cadastro"
|
||
@save="M.onDialogSave"
|
||
@delete="M.onDialogDelete"
|
||
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
||
@editSeriesOccurrence="M.onEditSeriesOccurrence"
|
||
/>
|
||
|
||
<!-- 2º AgendaEventDialog — empilhado por cima do principal pra editar
|
||
uma OCORRÊNCIA específica de série. Acionado pelo botão "Editar"
|
||
nas pills da lista "Recorrências Aplicadas". Reusa os mesmos
|
||
handlers de save/delete/update — o composable distingue pelo
|
||
id/recurrence_date. PrimeVue empilha automaticamente, então
|
||
nenhum gerenciamento manual de z-index é necessário.
|
||
Adicionado 2026-05-11; pendente replicar em Rail/Clínica. -->
|
||
<AgendaEventDialog
|
||
v-model="agendaOccDialogOpen"
|
||
:eventRow="agendaOccDialogEventRow"
|
||
:initialStartISO="agendaOccDialogStartISO"
|
||
:initialEndISO="agendaOccDialogEndISO"
|
||
:ownerId="agendaOwnerId"
|
||
:tenantId="agendaClinicTenantId"
|
||
:commitmentOptions="agendaCommitmentOptions"
|
||
:workRules="agendaWorkRules"
|
||
:blockedDates="[]"
|
||
:agendaSettings="agendaSettings"
|
||
:allEvents="agendaAllEvents"
|
||
:pausasSemanais="agendaSettings?.pausas_semanais || []"
|
||
:feriados="agendaFeriados"
|
||
newPatientRoute="/therapist/patients/cadastro"
|
||
:occurrenceMode="true"
|
||
@save="M.onDialogSave"
|
||
@delete="M.onDialogDelete"
|
||
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
||
/>
|
||
|
||
<!-- BloqueioDialog — bloqueio de horário/período/dia/feriados.
|
||
Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
|
||
refetcha pra refletir o bloqueio na agenda. -->
|
||
<BloqueioDialog
|
||
v-model="agendaBloqueioOpen"
|
||
:mode="agendaBloqueioMode"
|
||
:workRules="agendaWorkRules"
|
||
:settings="agendaSettings"
|
||
:ownerId="agendaOwnerId"
|
||
:tenantId="agendaClinicTenantId"
|
||
@saved="M.refetch"
|
||
/>
|
||
|
||
<!-- Dialog "Lançamentos da sessão" (2026-05-14): lista todos os
|
||
financial_records vinculados ao evento atual. Abre via botão
|
||
"Lançamentos" na seção Financeiro do MelissaEventoPanel. -->
|
||
<Dialog
|
||
v-model:visible="lancamentosDialogOpen"
|
||
modal
|
||
:draggable="false"
|
||
:style="{ width: '640px', maxWidth: '96vw' }"
|
||
>
|
||
<template #header>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-base font-bold">Lançamentos da sessão</span>
|
||
<span class="text-xs opacity-70">{{ lancamentosEventoTitulo }}</span>
|
||
</div>
|
||
</template>
|
||
<div v-if="lancamentosLoading" class="py-6 text-center text-sm opacity-70">
|
||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||
</div>
|
||
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
|
||
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
|
||
</div>
|
||
<div v-else class="flex flex-col gap-2.5">
|
||
<div
|
||
v-for="(r, idx) in lancamentosList"
|
||
:key="r.id"
|
||
class="ml-lanc-card"
|
||
:class="{ 'ml-lanc-card--child': idx > 0 }"
|
||
>
|
||
<div class="ml-lanc-card__head">
|
||
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
|
||
<span class="ml-lanc-card__desc">{{ r.description || (idx === 0 ? 'Sessão' : 'Cobrança extra') }}</span>
|
||
<Tag :value="_lancStatusLabels[r.status] || r.status" :severity="_lancStatusSeverity(r.status)" class="text-xs ml-auto" />
|
||
</div>
|
||
<div class="ml-lanc-card__body">
|
||
<div class="ml-lanc-card__row">
|
||
<i class="pi pi-money-bill" />
|
||
<span class="ml-lanc-card__amount">{{ _fmtLancBRL(r.final_amount || r.amount) }}</span>
|
||
</div>
|
||
<div v-if="r.payment_method" class="ml-lanc-card__row">
|
||
<i class="pi pi-credit-card" />
|
||
<span>{{ _lancMethodLabels[r.payment_method] || r.payment_method }}</span>
|
||
</div>
|
||
<div class="ml-lanc-card__row">
|
||
<i class="pi pi-calendar" />
|
||
<span>Vencimento: {{ _fmtLancDate(r.due_date) }}</span>
|
||
</div>
|
||
<div v-if="r.paid_at" class="ml-lanc-card__row">
|
||
<i class="pi pi-check-circle" />
|
||
<span>Pago em {{ _fmtLancDate(r.paid_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button label="Fechar" severity="secondary" outlined @click="lancamentosDialogOpen = false" />
|
||
</template>
|
||
</Dialog>
|
||
|
||
<!-- Dialog "Antecipar pagamento" (Fase 5, 2026-05-14): paciente
|
||
quer pagar antes da sessão. Materializa ocorrência se virtual
|
||
e cria/atualiza financial_record. Não decrementa saldo. -->
|
||
<Dialog
|
||
v-model:visible="anteciparDialogOpen"
|
||
modal
|
||
:draggable="false"
|
||
header="Antecipar pagamento"
|
||
:style="{ width: '480px', maxWidth: '96vw' }"
|
||
>
|
||
<div class="flex flex-col gap-3 pt-1">
|
||
<div class="text-sm">
|
||
Receba antecipadamente o valor desta sessão.
|
||
</div>
|
||
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
|
||
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5">
|
||
<label class="text-xs font-medium">Como o paciente pagou?</label>
|
||
<Select
|
||
v-model="anteciparMethod"
|
||
:options="anteciparMethodOptions"
|
||
optionLabel="label"
|
||
optionValue="value"
|
||
size="small"
|
||
/>
|
||
</div>
|
||
<small class="text-xs opacity-60">
|
||
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
|
||
</small>
|
||
</div>
|
||
<template #footer>
|
||
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
|
||
<Button label="Confirmar" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
|
||
</template>
|
||
</Dialog>
|
||
|
||
<!-- AgendaStatusChangeConfirmDialog — Fase 5 (2026-05-14): aparece
|
||
quando user muda status pra realizado/faltou/cancelado e há
|
||
decisão a tomar (regra de exceção, pacote saldo, pending). -->
|
||
<AgendaStatusChangeConfirmDialog
|
||
v-model="agendaStatusDialogOpen"
|
||
:evento="agendaStatusDialogProps.evento"
|
||
:novoStatus="agendaStatusDialogProps.novoStatus"
|
||
:regraExcecao="agendaStatusDialogProps.regraExcecao"
|
||
:billingContract="agendaStatusDialogProps.billingContract"
|
||
:billingContractStyle="agendaStatusDialogProps.billingContractStyle"
|
||
:pendingRecord="agendaStatusDialogProps.pendingRecord"
|
||
:sessionPrice="agendaStatusDialogProps.sessionPrice"
|
||
@confirm="agendaOnStatusDialogConfirm"
|
||
@update:modelValue="(v) => !v && agendaOnStatusDialogCancel()"
|
||
/>
|
||
|
||
<!-- Toast: AppLayout não monta no Melissa (rota fullscreen),
|
||
então as pages embedadas (config, agendador online, etc.)
|
||
precisam de um Toast próprio aqui pra não silenciar o
|
||
feedback de save. -->
|
||
<Toast />
|
||
|
||
<!-- Drawer de notificações (idem: AppTopbar não monta aqui) -->
|
||
<NotificationDrawer />
|
||
|
||
<!-- Plan menu DEV — popup ancorado no botão da topbar -->
|
||
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ─── Root ─────────────────────────────────────────────────── */
|
||
.win11-root {
|
||
position: fixed;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
}
|
||
/* Camada da foto custom — fica entre o gradiente default (.win11-root)
|
||
e o dim. Opacidade vem do slider via inline style. */
|
||
.win11-photo {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-size: cover;
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
pointer-events: none;
|
||
transition: opacity 200ms ease;
|
||
}
|
||
.win11-dim {
|
||
position: absolute;
|
||
inset: 0;
|
||
transition: background-color 200ms ease;
|
||
}
|
||
|
||
/* (Removido) .melissa-config-aside-host — o sidebar global foi
|
||
substituido pelo botao + MelissaConfigPopover em cada pagina de
|
||
config. CSS var --m-config-aside-left tambem nao e mais setada
|
||
aqui; as pages tem `inset: ... 6px` fixo de novo. */
|
||
|
||
/* ─── Plano de trás (resumo) ───────────────────────────────────
|
||
Animacao composicao-pura (transform + opacity) — ambos sao free
|
||
no compositor (nao re-pintam, nao re-layout). O blur deixou de
|
||
estar AQUI (era filter no container = re-render de toda a arvore
|
||
a cada frame, com layer explosion via backdrop-filter dos
|
||
glass-panels filhos). Agora vem do .win11-blur-veil (irmao
|
||
abaixo) — 1 backdrop pass unico em vez de N×N.
|
||
|
||
will-change + translateZ(0): promove pra layer dedicada ANTES
|
||
do transition disparar. Sem isso, o browser cria a layer no
|
||
primeiro frame e gera stutter visivel.
|
||
|
||
backface-visibility: evita sub-pixel jitter no scale(0.98).
|
||
*/
|
||
.win11-summary {
|
||
position: relative;
|
||
z-index: 10;
|
||
width: 100%;
|
||
height: 100%;
|
||
transition: transform 320ms ease, opacity 320ms ease;
|
||
will-change: transform, opacity;
|
||
transform: translateZ(0);
|
||
backface-visibility: hidden;
|
||
}
|
||
.win11-summary.is-behind {
|
||
transform: scale(0.98) translateZ(0);
|
||
opacity: 0.55;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ─── Veu de blur sobre o resumo ───────────────────────────────
|
||
1 unica camada com backdrop-filter cobrindo o resumo. Substitui
|
||
o filter:blur antigo aplicado no .win11-summary inteiro. Em vez
|
||
de blurar 5+ glass-panels filhos (cada um com seu proprio
|
||
backdrop-filter), e' UM blur unico que pinta o resultado
|
||
acumulado do resumo abaixo.
|
||
|
||
z-index 11: acima do .win11-summary (10) e abaixo dos overlays
|
||
de seção/cronometro/menu (≥30). pointer-events: none deixa o
|
||
click atravessar pra o overlay aberto.
|
||
|
||
contain: strict promove layer + isola repaints do veu do resto
|
||
do .win11-root (fix de "first-frame stutter"). will-change na
|
||
opacity prepara o compositor pro transition.
|
||
|
||
Mobile: blur de 3px ainda eh caro em GPU mid-tier; reduz pra
|
||
2px no media query abaixo. */
|
||
.win11-blur-veil {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 11;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 320ms ease;
|
||
backdrop-filter: blur(3px);
|
||
-webkit-backdrop-filter: blur(3px);
|
||
will-change: opacity;
|
||
contain: strict;
|
||
}
|
||
.win11-blur-veil.is-active {
|
||
opacity: 1;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.win11-blur-veil {
|
||
backdrop-filter: blur(2px);
|
||
-webkit-backdrop-filter: blur(2px);
|
||
}
|
||
}
|
||
.win11-summary__inner {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
/* `safe center`: centraliza vertical quando o conteúdo cabe;
|
||
vira flex-start (sem clipar) quando excede e exige scroll */
|
||
justify-content: safe center;
|
||
padding: 4rem 2rem 6rem;
|
||
/* Scrollbar discreta */
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--m-border-strong) transparent;
|
||
}
|
||
.win11-summary__inner::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.win11-summary__inner::-webkit-scrollbar-thumb {
|
||
background: var(--m-border-strong);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* ─── Relógio ──────────────────────────────────────────────── */
|
||
/* ─── Glass primitives ─────────────────────────────────────── */
|
||
.glass-panel {
|
||
background: var(--m-bg-soft);
|
||
backdrop-filter: blur(24px) saturate(140%);
|
||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||
border: 1px solid var(--m-border);
|
||
border-radius: 16px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.glass-btn {
|
||
background: var(--m-bg-soft);
|
||
backdrop-filter: blur(20px);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 9999px;
|
||
transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||
cursor: pointer;
|
||
}
|
||
.glass-btn:hover {
|
||
background: var(--m-bg-soft-hover);
|
||
}
|
||
.glass-btn:disabled {
|
||
cursor: default;
|
||
opacity: 0.6;
|
||
}
|
||
/* Active state (drawer aberto, popover aberto, etc.) — primary tint */
|
||
.glass-btn--active {
|
||
background: color-mix(in srgb, var(--p-primary-color) 24%, transparent);
|
||
border-color: color-mix(in srgb, var(--p-primary-color) 55%, transparent);
|
||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--p-primary-color) 35%, transparent);
|
||
}
|
||
|
||
/* Badge de count (notificações não lidas) — espelha .dock-pin__badge
|
||
mas em contexto de glass-btn topbar (canto sup. dir.). */
|
||
.m-topbar-badge {
|
||
position: absolute;
|
||
top: -4px;
|
||
right: -4px;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 9999px;
|
||
background: rgb(239, 68, 68);
|
||
color: white;
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
display: grid;
|
||
place-items: center;
|
||
line-height: 1;
|
||
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ─── Shell: cards-row scroll/wrap + card "+" sempre fora ──── */
|
||
.cards-shell {
|
||
display: flex;
|
||
align-items: stretch; /* "+" estica até a altura do row (linha única) */
|
||
gap: 1rem;
|
||
/* Sem padding aqui: o respiro vai dentro do .cards-row (pra estar
|
||
dentro da área que tem overflow controlado). */
|
||
}
|
||
.cards-shell.is-wrap {
|
||
align-items: flex-start; /* em wrap o "+" não estica (cards-row pode ter 2+ linhas) */
|
||
}
|
||
|
||
/* Row interno: ocupa o espaço restante; comporta cards (scroll ou wrap) */
|
||
.cards-row {
|
||
flex: 1;
|
||
min-width: 0; /* essencial pra flex item permitir overflow-x */
|
||
display: flex;
|
||
gap: 1rem;
|
||
}
|
||
.cards-row--linha {
|
||
flex-wrap: nowrap;
|
||
overflow-x: auto;
|
||
/* `overflow-y: hidden` explícito suprime o scroll vertical que o
|
||
browser adicionaria (overflow-x:auto força overflow-y:auto). O
|
||
padding abaixo já deu altura suficiente pra lift e botão "+". */
|
||
overflow-y: hidden;
|
||
/* `safe center`: centraliza quando os cards cabem; vira start
|
||
(sem clipar) quando overflow exigir scroll horizontal */
|
||
justify-content: safe center;
|
||
/* Padding interno (só nesse modo, pq o overflow-y:hidden exige):
|
||
- top: 8px → respiro pro hover-lift
|
||
- bottom: 24px → respiro pro botão "+" do card (bottom: -16px)
|
||
- lateral: 4px → pra cards das pontas não colarem */
|
||
padding: 8px 4px 24px;
|
||
/* Scrollbar horizontal discreta */
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--m-border-strong) transparent;
|
||
}
|
||
.cards-row--linha::-webkit-scrollbar {
|
||
height: 6px;
|
||
}
|
||
.cards-row--linha::-webkit-scrollbar-thumb {
|
||
background: var(--m-border-strong);
|
||
border-radius: 3px;
|
||
}
|
||
.cards-row--wrap {
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
/* Wrap não precisa do padding/overflow do linha-única.
|
||
row-gap maior pra botão "+" da linha de cima não colar nas
|
||
cards da linha de baixo. */
|
||
row-gap: 2rem;
|
||
padding-bottom: 16px;
|
||
}
|
||
|
||
/* Card "+" em linha única: tamanho fixo + margin pra alinhar
|
||
visualmente com os cards (que estão dentro do padding-top: 8 da row).
|
||
Usa :deep() pra atravessar o scope do MelissaCard.vue. */
|
||
.cards-shell:not(.is-wrap) :deep(.mc-card-add) {
|
||
height: 100px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
/* Mobile: "+" vira pill mínimo na linha única (economiza horizontal) */
|
||
@media (max-width: 640px) {
|
||
.cards-shell:not(.is-wrap) :deep(.mc-card-add) {
|
||
width: 30px;
|
||
border-radius: 9px;
|
||
}
|
||
}
|
||
|
||
/* ─── Central de cards (dialog de personalização) ─────────── */
|
||
.central-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 56;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
}
|
||
.central-panel {
|
||
width: min(440px, 100%);
|
||
max-height: 80vh;
|
||
overflow: auto;
|
||
background: var(--m-bg-medium);
|
||
backdrop-filter: blur(32px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 22px;
|
||
padding: 1.75rem;
|
||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||
}
|
||
.central-radio {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--m-text);
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
padding: 6px 8px;
|
||
border-radius: 8px;
|
||
transition: background-color 140ms ease;
|
||
}
|
||
.central-radio:hover {
|
||
background: var(--m-bg-soft);
|
||
}
|
||
.central-radio input[type='radio'] {
|
||
accent-color: #34d399;
|
||
cursor: pointer;
|
||
}
|
||
.central-card-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--m-text);
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
transition: background-color 140ms ease;
|
||
}
|
||
.central-card-row:hover {
|
||
background: var(--m-bg-soft);
|
||
}
|
||
.central-card-row input[type='checkbox'] {
|
||
accent-color: #34d399;
|
||
cursor: pointer;
|
||
}
|
||
.central-card-row.is-em-breve {
|
||
color: var(--m-text-muted);
|
||
}
|
||
|
||
/* ─── Botão ψ (avatar — futuramente logo da empresa) ─────── */
|
||
/* z-index alto: sempre acima de QUALQUER overlay (page, menu, dialogs)
|
||
pra continuar acessível mesmo com cronômetro/agenda/secao abertos.
|
||
Vive centralizado verticalmente na faixa do .melissa-dock (76px) —
|
||
bottom calculado pra centralizar o ψ de 56px na faixa: (76-56)/2 = 10px. */
|
||
.psi-btn {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
left: 1.25rem;
|
||
z-index: 70;
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
background: var(--m-bg-soft-hover);
|
||
backdrop-filter: blur(24px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||
border: 1px solid var(--m-border-strong);
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
transition: all 200ms ease;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
/* (.melissa-dock vive no bloco <style> global no fim do arquivo —
|
||
tem que ser global porque (a) o nó <div class="melissa-dock"/> é
|
||
static-hoisted pelo compiler e perde o data-v scoped, e (b) recebe
|
||
filhos via Teleport de outros componentes que precisam herdar pointer-events) */
|
||
.psi-btn:hover {
|
||
background: var(--m-border-strong);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||
}
|
||
.psi-glyph {
|
||
font-family: 'Instrument Serif', Georgia, serif;
|
||
font-size: 2rem;
|
||
line-height: 1;
|
||
color: white;
|
||
font-style: italic;
|
||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||
/* ψ tem descender pronunciado (cauda que vai abaixo da baseline);
|
||
o `place-items: center` do botão centraliza pela line-box, não pelo
|
||
glifo, então empurro 3px pra cima pra equilibrar visualmente. */
|
||
transform: translateY(-3px);
|
||
}
|
||
|
||
/* Pill overlay com atalho de teclado, ancorado no canto inferior-direito do ψ */
|
||
.psi-kbd {
|
||
position: absolute;
|
||
bottom: calc(-15% + 8px);
|
||
left: 60%;
|
||
/* Bg/color usam tokens — em dark vira pílula escura com texto claro,
|
||
em light vira pílula clara com texto escuro. --m-kbd-* definidos
|
||
no bloco global de tokens no fim do arquivo. */
|
||
background: var(--m-kbd-bg);
|
||
color: var(--m-kbd-text);
|
||
padding: 3px 9px;
|
||
border-radius: 9999px;
|
||
font-size: 0.6rem;
|
||
font-weight: 500;
|
||
letter-spacing: 0.05em;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
border: 1px solid var(--m-border-strong);
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
}
|
||
|
||
/* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */
|
||
/* ─── Seção: overlay grande (Pacientes, WhatsApp, etc.) ─────── */
|
||
.secao-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 50;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
}
|
||
.secao-panel {
|
||
width: min(1100px, 100%);
|
||
height: min(80vh, 720px);
|
||
background: var(--m-bg-medium);
|
||
backdrop-filter: blur(32px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 22px;
|
||
padding: 1.75rem;
|
||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.secao-icon {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 12px;
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border-strong);
|
||
display: grid;
|
||
place-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.secao-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: auto;
|
||
padding: 1rem;
|
||
}
|
||
.secao-body-icon {
|
||
font-size: 4.5rem;
|
||
color: var(--m-text-faint);
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
/* (Antes vivia aqui o CSS do panel inline de evento — extraído pra
|
||
MelissaEventoPanel.vue em 2026-04-27 junto com o componente.) */
|
||
|
||
/* (removido em 2026-04-29: .page-fade-* não é mais usado. As páginas
|
||
fullscreen perderam o <Transition> wrapper porque o leave delay
|
||
gerava orphan placeholders nos Teleport targets compartilhados.
|
||
Animação de entrada agora vive como @keyframes nos próprios
|
||
componentes — ver .ma-page / .mp-page.) */
|
||
|
||
/* ─── MelissaMenu — transição "rise" no .mm-panel (não no layer) ─── */
|
||
.menu-rise-enter-active :deep(.mm-panel),
|
||
.menu-rise-leave-active :deep(.mm-panel) {
|
||
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||
transform-origin: bottom left;
|
||
}
|
||
.menu-rise-enter-from :deep(.mm-panel) {
|
||
opacity: 0;
|
||
transform: translateY(16px) scale(0.97);
|
||
}
|
||
.menu-rise-leave-to :deep(.mm-panel) {
|
||
opacity: 0;
|
||
transform: translateY(8px) scale(0.98);
|
||
}
|
||
|
||
/* Em mobile (<lg) o painel vira drawer da esquerda — anima translateX
|
||
em vez de translateY+scale. Backdrop do .mm-layer também anima opacity. */
|
||
@media (max-width: 1023px) {
|
||
.menu-rise-enter-active :deep(.mm-panel),
|
||
.menu-rise-leave-active :deep(.mm-panel) {
|
||
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.menu-rise-enter-from :deep(.mm-panel),
|
||
.menu-rise-leave-to :deep(.mm-panel) {
|
||
opacity: 1;
|
||
transform: translateX(-100%);
|
||
}
|
||
.menu-rise-enter-active :deep(.mm-layer),
|
||
.menu-rise-leave-active :deep(.mm-layer) {
|
||
transition: opacity 240ms ease;
|
||
}
|
||
.menu-rise-enter-from :deep(.mm-layer),
|
||
.menu-rise-leave-to :deep(.mm-layer) {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
/* ─── Transições ───────────────────────────────────────────── */
|
||
.lift-enter-active,
|
||
.lift-leave-active {
|
||
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||
}
|
||
.lift-enter-from {
|
||
opacity: 0;
|
||
transform: scale(0.96) translateY(12px);
|
||
}
|
||
.lift-leave-to {
|
||
opacity: 0;
|
||
transform: scale(0.98) translateY(8px);
|
||
}
|
||
|
||
.settings-pop-enter-active,
|
||
.settings-pop-leave-active {
|
||
transition: opacity 160ms ease, transform 160ms ease;
|
||
}
|
||
.settings-pop-enter-from,
|
||
.settings-pop-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-6px) scale(0.98);
|
||
}
|
||
|
||
/* line-clamp util (caso Tailwind não tenha) */
|
||
.line-clamp-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Dialog "Lançamentos da sessão" (2026-05-14) ── */
|
||
.ml-lanc-card {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 8px;
|
||
padding: 0.7rem 0.85rem;
|
||
background: var(--surface-card);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.ml-lanc-card--child {
|
||
background: color-mix(in srgb, var(--p-primary-color) 4%, var(--surface-card));
|
||
margin-left: 1.5rem;
|
||
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
|
||
}
|
||
.ml-lanc-card__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.ml-lanc-card__indent {
|
||
color: var(--text-color-secondary);
|
||
font-size: 0.7rem;
|
||
transform: scaleY(-1);
|
||
}
|
||
.ml-lanc-card__desc {
|
||
font-size: 0.88rem;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
}
|
||
.ml-lanc-card__body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
.ml-lanc-card__row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
font-size: 0.78rem;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
.ml-lanc-card__row i { font-size: 0.72rem; }
|
||
.ml-lanc-card__amount {
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
}
|
||
</style>
|
||
|
||
<!-- ────────────────────────────────────────────────────────────
|
||
Variáveis de tema Melissa — bloco GLOBAL (não scoped) pra
|
||
atravessar o scoped CSS dos componentes filhos (MelissaMenu,
|
||
MelissaCronometro, MelissaAgenda, MelissaCard, MelissaBusca).
|
||
|
||
Defaults (modo escuro):
|
||
--m-bg-soft bg sutil em superfícies internas (cards, hovers)
|
||
--m-bg-medium bg dos painéis flutuantes (panel principal)
|
||
--m-text cor de texto principal
|
||
--m-text-muted texto secundário
|
||
--m-border cor de bordas glass
|
||
--m-accent cor primária do tema (PrimeVue --p-primary-color)
|
||
--m-accent-soft tint sutil da primária pra active/hover
|
||
|
||
Em modo light (html sem .app-dark) os valores invertem.
|
||
──────────────────────────────────────────────────────────── -->
|
||
<style>
|
||
/* Tokens Melissa derivados dos tokens semânticos do PrimeVue (--p-*) que
|
||
já flipam automaticamente com dark/light AND seguem o preset escolhido
|
||
(Aura/Lara/Nora/Material têm escalas e radii próprios). */
|
||
.win11-root {
|
||
/* Cor primária — vem do preset escolhido pelo user */
|
||
--m-accent: var(--p-primary-color);
|
||
--m-accent-soft: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
|
||
--m-accent-strong: color-mix(in srgb, var(--p-primary-color) 35%, transparent);
|
||
|
||
/* Glass derivado do content/surface tokens com alpha pra preservar
|
||
o efeito translúcido sobre a photo. Auto-flip dark/light. */
|
||
--m-bg-soft: color-mix(in srgb, var(--p-content-background) 50%, transparent);
|
||
--m-bg-soft-hover: color-mix(in srgb, var(--p-content-hover-background, var(--p-surface-100)) 70%, transparent);
|
||
--m-bg-medium: color-mix(in srgb, var(--p-content-background) 88%, transparent);
|
||
|
||
/* Texto e bordas — auto-flip via PrimeVue */
|
||
--m-text: var(--p-text-color);
|
||
--m-text-muted: var(--p-text-muted-color);
|
||
--m-text-faint: color-mix(in srgb, var(--p-text-color) 45%, transparent);
|
||
--m-border: var(--p-content-border-color);
|
||
--m-border-strong: color-mix(in srgb, var(--p-text-color) 22%, transparent);
|
||
|
||
/* Border-radius do preset (Aura usa 12px, Nora 6px, Lara 6px etc.) */
|
||
--m-radius: var(--p-border-radius, 8px);
|
||
--m-radius-lg: calc(var(--p-border-radius, 8px) * 1.5);
|
||
|
||
/* RGB do dim (overlay sobre a photo). Opacidade vem do slider via
|
||
rgba(var(--m-dim-rgb), opacity) no inline style. Único token que
|
||
precisa de override manual (PrimeVue não tem "dim" nativo). */
|
||
--m-dim-rgb: 0, 0, 0;
|
||
|
||
/* ─── Gradiente "Bloom" (default = dark) ────────────────────────
|
||
Derivado da palette primária + surface do preset. Em light flipam
|
||
pra tons claros (200/100 + surface-0/primary-50). O defaultBgStyle
|
||
no <script> referencia essas vars; mudar aqui muda o bloom todo. */
|
||
--bloom-c1: var(--p-primary-400);
|
||
--bloom-c2: var(--p-primary-300);
|
||
--bloom-base-1: var(--p-surface-950);
|
||
--bloom-base-2: var(--p-primary-950);
|
||
|
||
/* ─── Pílula de atalho de teclado (psi-kbd) ─────────────────────
|
||
Em dark: pílula escura com texto claro. Em light: pílula clara
|
||
com texto escuro (override no bloco light abaixo). */
|
||
--m-kbd-bg: rgba(0, 0, 0, 0.65);
|
||
--m-kbd-text: rgba(255, 255, 255, 0.92);
|
||
|
||
/* ─── Dock (taskbar transparent) ────────────────────────────────
|
||
Altura da faixa inferior que recebe items minimizados (chip do
|
||
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 com gradiente
|
||
espelhado: cor solida na esquerda (onde os pins ficam) e fade
|
||
pra transparente na direita. */
|
||
.melissa-dock {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: var(--m-dock-h, 76px);
|
||
z-index: 65; /* abaixo do ψ (70) e overlays modais */
|
||
display: flex;
|
||
align-items: center;
|
||
/* padding-left reserva ~96px pro ψ (56px + 1.25rem left + gap) */
|
||
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; }
|
||
|
||
/* ─── Dock pinned items (Agenda, WhatsApp) ──────────────────────
|
||
Tamanho menor que o ψ (44px vs 56px) + canto arredondado em vez
|
||
de full-circle: hierarquia visual auto-resolvida. ψ = "tudo",
|
||
pinned = atalhos. Hover lifta + ring. Active state mostra qual
|
||
seção tá aberta. */
|
||
.dock-pin {
|
||
position: relative;
|
||
width: 44px;
|
||
height: 44px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 12px;
|
||
background: var(--m-bg-soft, rgba(255, 255, 255, 0.06));
|
||
backdrop-filter: blur(20px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.10));
|
||
color: var(--m-text, white);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||
}
|
||
.dock-pin > i {
|
||
font-size: 1.05rem;
|
||
opacity: 0.92;
|
||
}
|
||
.dock-pin:hover {
|
||
transform: translateY(-3px);
|
||
background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.10));
|
||
border-color: var(--m-border-strong, rgba(255, 255, 255, 0.18));
|
||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
||
}
|
||
.dock-pin:active {
|
||
transform: translateY(-1px);
|
||
}
|
||
.dock-pin--active {
|
||
background: color-mix(in srgb, var(--p-primary-color) 22%, transparent);
|
||
border-color: color-mix(in srgb, var(--p-primary-color) 55%, transparent);
|
||
box-shadow:
|
||
0 6px 18px rgba(0, 0, 0, 0.25),
|
||
0 0 0 1px color-mix(in srgb, var(--p-primary-color) 40%, transparent);
|
||
}
|
||
.dock-pin--active > i { opacity: 1; }
|
||
|
||
/* Badge de count (WhatsApp não-lidas) */
|
||
.dock-pin__badge {
|
||
position: absolute;
|
||
top: -4px;
|
||
right: -4px;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 9999px;
|
||
background: rgb(239, 68, 68);
|
||
color: white;
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
display: grid;
|
||
place-items: center;
|
||
line-height: 1;
|
||
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Light mode: dock-pin precisa de mais contraste sobre bloom claro */
|
||
html:not(.app-dark) .melissa-dock .dock-pin {
|
||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10);
|
||
}
|
||
html:not(.app-dark) .melissa-dock .dock-pin:hover {
|
||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
|
||
}
|
||
|
||
/* ─── Pins dinâmicos do dock (híbrido fixo + recente) ───────── */
|
||
/* Divisor entre builtins e dinâmicos. Fininho, atravessa o gap. */
|
||
.dock-divider {
|
||
width: 1px;
|
||
height: 24px;
|
||
align-self: center;
|
||
background: var(--m-border, rgba(255, 255, 255, 0.18));
|
||
opacity: 0.7;
|
||
}
|
||
html:not(.app-dark) .dock-divider {
|
||
background: rgba(15, 23, 42, 0.18);
|
||
}
|
||
|
||
/* Pin fixado pelo user — pequena marca no canto pra diferenciar do recente. */
|
||
.dock-pin--user .dock-pin__pinned-mark {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: color-mix(in srgb, var(--p-primary-color) 70%, white);
|
||
box-shadow: 0 0 6px color-mix(in srgb, var(--p-primary-color) 50%, transparent);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Pin recente (MRU) — visualmente mais leve pra denotar transitoriedade.
|
||
Fica entre opacity total (active/hover) e ~70% no estado normal. */
|
||
.dock-pin--recent {
|
||
opacity: 0.78;
|
||
}
|
||
.dock-pin--recent:hover,
|
||
.dock-pin--recent.dock-pin--active {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
|
||
componentes filhos). Use a classe .melissa-skeleton em qualquer
|
||
container — vira um placeholder com shimmer suave.
|
||
Variantes via classes adicionais:
|
||
.melissa-skeleton--text altura ~12px (linha de texto)
|
||
.melissa-skeleton--title altura ~18px (heading)
|
||
.melissa-skeleton--avatar redondo 32px
|
||
.melissa-skeleton--block altura definida via inline style
|
||
Shimmer respeita prefers-reduced-motion. */
|
||
.melissa-skeleton {
|
||
background: linear-gradient(
|
||
90deg,
|
||
var(--m-bg-soft, rgba(255, 255, 255, 0.04)) 0%,
|
||
var(--m-bg-soft-hover, rgba(255, 255, 255, 0.08)) 50%,
|
||
var(--m-bg-soft, rgba(255, 255, 255, 0.04)) 100%
|
||
);
|
||
background-size: 200% 100%;
|
||
animation: melissa-shimmer 1400ms linear infinite;
|
||
border-radius: 6px;
|
||
flex-shrink: 0;
|
||
pointer-events: none;
|
||
}
|
||
.melissa-skeleton--text { height: 12px; }
|
||
.melissa-skeleton--title { height: 18px; }
|
||
.melissa-skeleton--number { height: 24px; width: 32px; }
|
||
.melissa-skeleton--avatar { width: 32px; height: 32px; border-radius: 50%; }
|
||
@keyframes melissa-shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.melissa-skeleton { animation: none; }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
LIGHT MODE — aesthetic "Pippit/Linear": superfícies sólidas,
|
||
bordas finas, sombras suaves. Sem photo nem glass blur.
|
||
Texto SEMPRE legível porque vem do --p-text-color sobre superfície
|
||
sólida (não depende do slider de opacidade nem da photo).
|
||
════════════════════════════════════════════════════════════════ */
|
||
html:not(.app-dark) .win11-root {
|
||
/* Surfaces sólidas (sem alpha) — vem dos tokens PrimeVue */
|
||
--m-bg-soft: var(--p-surface-100, #f3f4f6);
|
||
--m-bg-soft-hover: var(--p-surface-200, #e5e7eb);
|
||
--m-bg-medium: var(--p-content-background); /* opaco */
|
||
|
||
/* Bordas mais sutis em light (Pippit-style) */
|
||
--m-border: var(--p-content-border-color);
|
||
--m-border-strong: color-mix(in srgb, var(--p-text-color) 14%, transparent);
|
||
|
||
/* Sombras leves (substituem o glass blur em light) */
|
||
--m-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.05);
|
||
--m-shadow-md: 0 2px 6px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.08);
|
||
|
||
/* Dim só aparece quando há foto custom (ver regra .win11-has-photo
|
||
no fim do arquivo). Sem foto, o bloom claro já é leve o suficiente. */
|
||
--m-dim-rgb: 255, 255, 255;
|
||
|
||
/* ─── Gradiente "Bloom" — versão clara (Pippit/Linear morning) ────
|
||
Tons 50/100/200 da primary + surface-0/50. Gradiente fica suave,
|
||
legível, dá identidade Win11 sem competir com o conteúdo. */
|
||
--bloom-c1: var(--p-primary-200);
|
||
--bloom-c2: var(--p-primary-100);
|
||
--bloom-base-1: var(--p-surface-0, #ffffff);
|
||
--bloom-base-2: var(--p-primary-50);
|
||
|
||
/* Pílula psi-kbd em light: bg escuro suave + texto branco
|
||
(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).
|
||
Quando o user faz upload de foto custom, .win11-has-photo é adicionada
|
||
e o dim volta a fazer sentido. */
|
||
html:not(.app-dark) .win11-root .win11-dim {
|
||
display: none;
|
||
}
|
||
html:not(.app-dark) .win11-root.win11-has-photo .win11-dim {
|
||
display: block;
|
||
}
|
||
|
||
/* Light: panels viram OPACOS com borda + shadow (sem backdrop-filter).
|
||
Aplica em todos os "glass-*" e cards Melissa via :is(). */
|
||
html:not(.app-dark) .win11-root :is(
|
||
.glass-panel,
|
||
.glass-btn,
|
||
.mc-card,
|
||
.mc-card-add,
|
||
.mc-card__go,
|
||
.mc-fc-event,
|
||
.mb-field,
|
||
.mb-panel,
|
||
.mb-item,
|
||
.mc-panel,
|
||
.mm-panel,
|
||
.secao-panel,
|
||
.central-panel,
|
||
.evento-panel,
|
||
.ma-page,
|
||
.ma-w,
|
||
.ma-sess,
|
||
.ma-ev,
|
||
.crono-icon-btn,
|
||
.psi-btn,
|
||
.psi-kbd,
|
||
.crono-chip,
|
||
.resumo-link,
|
||
.filtro-chip,
|
||
.module-tile,
|
||
.settings-select,
|
||
.settings-test-btn
|
||
) {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* Sombra suave nos painéis principais em light (substitui o "depth" do blur) */
|
||
html:not(.app-dark) .win11-root :is(
|
||
.glass-panel,
|
||
.mc-card,
|
||
.mc-panel,
|
||
.mm-panel,
|
||
.secao-panel,
|
||
.central-panel,
|
||
.evento-panel,
|
||
.ma-page,
|
||
.ma-w,
|
||
.mb-panel
|
||
) {
|
||
box-shadow: var(--m-shadow);
|
||
}
|
||
html:not(.app-dark) .win11-root :is(
|
||
.mm-panel,
|
||
.secao-panel,
|
||
.central-panel,
|
||
.evento-panel,
|
||
.mc-panel,
|
||
.ma-page
|
||
) {
|
||
box-shadow: var(--m-shadow-md);
|
||
}
|
||
|
||
/* ─── Override de classes Tailwind hardcoded (`text-white/X`, `bg-white/X`,
|
||
`border-white/X`) que vêm dos templates. Em light: textos viram dark,
|
||
bgs viram surface tokens. */
|
||
|
||
html:not(.app-dark) .win11-root .text-white,
|
||
html:not(.app-dark) .win11-root [class*="text-white/9"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/85"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/8"] {
|
||
color: var(--m-text);
|
||
}
|
||
html:not(.app-dark) .win11-root [class*="text-white/7"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/6"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/55"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/5"] {
|
||
color: var(--m-text-muted);
|
||
}
|
||
html:not(.app-dark) .win11-root [class*="text-white/4"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/3"],
|
||
html:not(.app-dark) .win11-root [class*="text-white/2"] {
|
||
color: var(--m-text-faint);
|
||
}
|
||
|
||
html:not(.app-dark) .win11-root [class*="bg-white/"] {
|
||
background-color: var(--m-bg-soft);
|
||
}
|
||
html:not(.app-dark) .win11-root [class*="bg-white/2"],
|
||
html:not(.app-dark) .win11-root [class*="bg-white/3"] {
|
||
background-color: var(--m-bg-soft-hover);
|
||
}
|
||
html:not(.app-dark) .win11-root [class*="hover:bg-white/"]:hover {
|
||
background-color: var(--m-bg-soft-hover) !important;
|
||
}
|
||
|
||
html:not(.app-dark) .win11-root [class*="border-white/"] {
|
||
border-color: var(--m-border);
|
||
}
|
||
html:not(.app-dark) .win11-root [class*="border-white/2"],
|
||
html:not(.app-dark) .win11-root [class*="border-white/3"] {
|
||
border-color: var(--m-border-strong);
|
||
}
|
||
|
||
/* Light: clock e ψ usam color: white hardcoded (CSS scoped, fora do alcance
|
||
dos overrides Tailwind). Sobre o bloom claro, branco vira invisível —
|
||
forçar var(--m-text) e shadow leve. */
|
||
html:not(.app-dark) .win11-root .clock-display {
|
||
color: var(--m-text);
|
||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
|
||
}
|
||
html:not(.app-dark) .win11-root .psi-glyph {
|
||
color: var(--m-text);
|
||
text-shadow: none;
|
||
}
|
||
|
||
/* Light: psi-btn ganha sombra suave (substitui a preta agressiva 24px)
|
||
+ um leve halo da primary pra destacar o botão sobre o bloom claro. */
|
||
html:not(.app-dark) .win11-root .psi-btn {
|
||
box-shadow:
|
||
0 4px 14px rgba(15, 23, 42, 0.08),
|
||
0 1px 3px rgba(15, 23, 42, 0.06),
|
||
0 0 0 1px color-mix(in srgb, var(--p-primary-color) 12%, transparent);
|
||
}
|
||
html:not(.app-dark) .win11-root .psi-btn:hover {
|
||
box-shadow:
|
||
0 8px 20px rgba(15, 23, 42, 0.12),
|
||
0 2px 6px rgba(15, 23, 42, 0.08),
|
||
0 0 0 1px color-mix(in srgb, var(--p-primary-color) 24%, transparent);
|
||
}
|
||
|
||
/* Light + bloom: panels com leve transparência pra deixar a cor do bloom
|
||
transparecer, mas mantendo legibilidade. 92% de opacidade em vez de 100%. */
|
||
html:not(.app-dark) .win11-root :is(
|
||
.glass-panel,
|
||
.mc-card,
|
||
.mc-panel,
|
||
.mm-panel,
|
||
.secao-panel,
|
||
.central-panel,
|
||
.evento-panel,
|
||
.ma-page,
|
||
.ma-w,
|
||
.mb-panel
|
||
) {
|
||
background-color: color-mix(in srgb, var(--p-content-background) 92%, transparent);
|
||
}
|
||
|
||
/* ─── Ícones com tons claros (Tailwind text-X-300/400) ────────
|
||
Tons claros são pensados pra fundo escuro (dark glass). Em light
|
||
ficam invisíveis sobre bloom claro/panels brancos. Mapeia pra
|
||
tons -600 (legível em fundo claro, mantém identidade da cor). */
|
||
html:not(.app-dark) .win11-root .text-emerald-200,
|
||
html:not(.app-dark) .win11-root .text-emerald-300,
|
||
html:not(.app-dark) .win11-root .text-emerald-400 { color: rgb(5 150 105); } /* emerald-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-green-200,
|
||
html:not(.app-dark) .win11-root .text-green-300,
|
||
html:not(.app-dark) .win11-root .text-green-400 { color: rgb(22 163 74); } /* green-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-amber-200,
|
||
html:not(.app-dark) .win11-root .text-amber-300,
|
||
html:not(.app-dark) .win11-root .text-amber-400 { color: rgb(217 119 6); } /* amber-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-purple-200,
|
||
html:not(.app-dark) .win11-root .text-purple-300,
|
||
html:not(.app-dark) .win11-root .text-purple-400 { color: rgb(147 51 234); } /* purple-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-blue-200,
|
||
html:not(.app-dark) .win11-root .text-blue-300,
|
||
html:not(.app-dark) .win11-root .text-blue-400 { color: rgb(37 99 235); } /* blue-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-sky-200,
|
||
html:not(.app-dark) .win11-root .text-sky-300,
|
||
html:not(.app-dark) .win11-root .text-sky-400 { color: rgb(2 132 199); } /* sky-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-cyan-200,
|
||
html:not(.app-dark) .win11-root .text-cyan-300,
|
||
html:not(.app-dark) .win11-root .text-cyan-400 { color: rgb(8 145 178); } /* cyan-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-teal-200,
|
||
html:not(.app-dark) .win11-root .text-teal-300,
|
||
html:not(.app-dark) .win11-root .text-teal-400 { color: rgb(13 148 136); } /* teal-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-indigo-200,
|
||
html:not(.app-dark) .win11-root .text-indigo-300,
|
||
html:not(.app-dark) .win11-root .text-indigo-400 { color: rgb(79 70 229); } /* indigo-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-violet-200,
|
||
html:not(.app-dark) .win11-root .text-violet-300,
|
||
html:not(.app-dark) .win11-root .text-violet-400 { color: rgb(124 58 237); } /* violet-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-pink-200,
|
||
html:not(.app-dark) .win11-root .text-pink-300,
|
||
html:not(.app-dark) .win11-root .text-pink-400 { color: rgb(219 39 119); } /* pink-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-rose-200,
|
||
html:not(.app-dark) .win11-root .text-rose-300,
|
||
html:not(.app-dark) .win11-root .text-rose-400 { color: rgb(225 29 72); } /* rose-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-red-200,
|
||
html:not(.app-dark) .win11-root .text-red-300,
|
||
html:not(.app-dark) .win11-root .text-red-400 { color: rgb(220 38 38); } /* red-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-orange-200,
|
||
html:not(.app-dark) .win11-root .text-orange-300,
|
||
html:not(.app-dark) .win11-root .text-orange-400 { color: rgb(234 88 12); } /* orange-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-yellow-200,
|
||
html:not(.app-dark) .win11-root .text-yellow-300,
|
||
html:not(.app-dark) .win11-root .text-yellow-400 { color: rgb(202 138 4); } /* yellow-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-fuchsia-200,
|
||
html:not(.app-dark) .win11-root .text-fuchsia-300,
|
||
html:not(.app-dark) .win11-root .text-fuchsia-400 { color: rgb(192 38 211); } /* fuchsia-600 */
|
||
|
||
html:not(.app-dark) .win11-root .text-lime-200,
|
||
html:not(.app-dark) .win11-root .text-lime-300,
|
||
html:not(.app-dark) .win11-root .text-lime-400 { color: rgb(101 163 13); } /* lime-600 */
|
||
</style>
|