}
+ */
+export async function listTemplates(context = {}) {
+ const globals = await _fetchAllGlobalTemplates()
+
+ return Promise.all(
+ globals.map(async (global) => {
+ const resolved = await getEmailTemplate(global.key, context)
+ return {
+ key: global.key,
+ domain: global.domain,
+ channel: global.channel,
+ version: global.version,
+ is_active: global.is_active,
+ variables: global.variables,
+ has_override: resolved._source === 'tenant',
+ needs_sync: resolved._needs_sync || false,
+ }
+ })
+ )
+}
diff --git a/src/lib/email/emailTemplates.reference.js b/src/lib/email/emailTemplates.reference.js
new file mode 100644
index 0000000..32cbf1e
--- /dev/null
+++ b/src/lib/email/emailTemplates.reference.js
@@ -0,0 +1,706 @@
+/**
+ * ╔══════════════════════════════════════════════════════════════╗
+ * ║ EMAIL TEMPLATE SYSTEM — Multi-tenant SaaS ║
+ * ║ Compatível com o modelo de dados do sistema terapêutico ║
+ * ╚══════════════════════════════════════════════════════════════╝
+ *
+ * Arquitetura:
+ * models/ → estrutura de dados e constantes
+ * renderer/ → processamento de variáveis e renderização
+ * service/ → lógica de resolução (global → tenant → owner)
+ * preview/ → geração de preview com dados mockados
+ *
+ * Decisões de design:
+ * - Template keys seguem domínio.ação (session.reminder, intake.confirmation)
+ * para facilitar agrupamento e expansão futura por módulo.
+ * - Três camadas de resolução: global → tenant → owner (therapist),
+ * espelhando como owner_id e tenant_id coexistem no banco.
+ * - Canal (email/whatsapp/sms) é first-class citizen no modelo,
+ * pois o sistema já tem modalidade online/presencial.
+ * - Versionamento preparado para sync futuro sem impactar consumers.
+ */
+
+// ─────────────────────────────────────────────
+// MODELS — Estrutura de dados
+// ─────────────────────────────────────────────
+
+/**
+ * Domínios de template organizados por módulo do sistema.
+ * Facilita listar templates disponíveis por área (ex: todos os de 'session').
+ */
+export const TEMPLATE_DOMAINS = {
+ SESSION: 'session', // Agendamento e sessões
+ INTAKE: 'intake', // Triagem e cadastros externos
+ BILLING: 'billing', // Cobranças e recibos (futuro)
+ SYSTEM: 'system', // Avisos do sistema, boas-vindas
+}
+
+/**
+ * Canais suportados — preparado para WhatsApp/SMS além de email.
+ * Templates podem ter variantes por canal com conteúdo diferente.
+ */
+export const TEMPLATE_CHANNELS = {
+ EMAIL: 'email',
+ WHATSAPP: 'whatsapp', // futuro
+ SMS: 'sms', // futuro
+}
+
+/**
+ * Chaves de template — identificadores únicos por domínio.ação.canal
+ *
+ * Estrutura: `{domain}.{action}.{channel}`
+ * Ex: session.reminder.email, intake.confirmation.whatsapp
+ *
+ * Manter centralizado aqui evita typos e facilita descoberta.
+ */
+export const TEMPLATE_KEYS = {
+ // Sessões
+ SESSION_REMINDER: 'session.reminder.email',
+ SESSION_CONFIRMATION: 'session.confirmation.email',
+ SESSION_CANCELLATION: 'session.cancellation.email',
+ SESSION_RESCHEDULED: 'session.rescheduled.email',
+
+ // Triagem / Cadastros externos (patient_intake_requests)
+ INTAKE_RECEIVED: 'intake.received.email',
+ INTAKE_APPROVED: 'intake.approved.email',
+ INTAKE_REJECTED: 'intake.rejected.email',
+
+ // Solicitações do agendador online (agendador_solicitacoes)
+ SCHEDULER_REQUEST_ACCEPTED: 'scheduler.request_accepted.email',
+ SCHEDULER_REQUEST_REJECTED: 'scheduler.request_rejected.email',
+
+ // Sistema
+ SYSTEM_WELCOME: 'system.welcome.email',
+ SYSTEM_PASSWORD_RESET: 'system.password_reset.email',
+}
+
+/**
+ * Schema de template global (tabela: email_templates_global)
+ *
+ * SQL equivalente:
+ * CREATE TABLE email_templates_global (
+ * id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ * key TEXT UNIQUE NOT NULL,
+ * domain TEXT NOT NULL,
+ * channel TEXT NOT NULL DEFAULT 'email',
+ * subject TEXT NOT NULL,
+ * body_html TEXT NOT NULL,
+ * body_text TEXT,
+ * version INTEGER NOT NULL DEFAULT 1,
+ * is_active BOOLEAN NOT NULL DEFAULT true,
+ * variables JSONB, -- variáveis esperadas com descrição
+ * created_at TIMESTAMPTZ DEFAULT now(),
+ * updated_at TIMESTAMPTZ DEFAULT now()
+ * );
+ */
+export function createGlobalTemplate(overrides = {}) {
+ return {
+ id: null,
+ key: '',
+ domain: TEMPLATE_DOMAINS.SYSTEM,
+ channel: TEMPLATE_CHANNELS.EMAIL,
+ subject: '',
+ body_html: '',
+ body_text: null,
+ version: 1,
+ is_active: true,
+ variables: {}, // { patient_name: 'Nome do paciente', ... }
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+ }
+}
+
+/**
+ * Schema de override por tenant (tabela: email_templates_tenant)
+ *
+ * SQL equivalente:
+ * CREATE TABLE email_templates_tenant (
+ * id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ * tenant_id UUID NOT NULL REFERENCES tenants(id),
+ * owner_id UUID REFERENCES auth.users(id), -- override por terapeuta específico
+ * template_key TEXT NOT NULL,
+ * subject TEXT, -- null = usar global
+ * body_html TEXT, -- null = usar global
+ * body_text TEXT,
+ * enabled BOOLEAN NOT NULL DEFAULT true,
+ * synced_version INTEGER, -- versão global que foi base deste override
+ * created_at TIMESTAMPTZ DEFAULT now(),
+ * updated_at TIMESTAMPTZ DEFAULT now(),
+ * UNIQUE (tenant_id, owner_id, template_key)
+ * );
+ */
+export function createTenantTemplate(overrides = {}) {
+ return {
+ id: null,
+ tenant_id: null,
+ owner_id: null, // null = vale para todo o tenant; preenchido = só para esse terapeuta
+ template_key: '',
+ subject: null, // null = herda do global
+ body_html: null, // null = herda do global
+ body_text: null,
+ enabled: true,
+ synced_version: null, // preparado para sync futuro
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+ }
+}
+
+// ─────────────────────────────────────────────
+// GLOBAL TEMPLATE STORE
+// Simula o banco de dados. Em produção, substituir pelas queries Supabase.
+// ─────────────────────────────────────────────
+
+export const globalTemplates = [
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.SESSION_REMINDER,
+ domain: TEMPLATE_DOMAINS.SESSION,
+ subject: 'Lembrete: sua sessão amanhã às {{session_time}}',
+ body_html: `
+ Olá, {{patient_name}} !
+ Este é um lembrete da sua sessão agendada para {{session_date}} às {{session_time}} .
+ Modalidade: {{session_modality}}
+ {{#if session_link}}
+ Clique aqui para entrar na sessão online
+ {{/if}}
+ Em caso de necessidade de cancelamento, entre em contato com antecedência.
+ Até logo,{{therapist_name}}
+ `,
+ body_text: `Olá, {{patient_name}}! Lembrete da sua sessão: {{session_date}} às {{session_time}} ({{session_modality}}).`,
+ variables: {
+ patient_name: 'Nome completo do paciente',
+ session_date: 'Data da sessão (ex: 20/03/2026)',
+ session_time: 'Horário da sessão (ex: 14:00)',
+ session_modality: 'Presencial ou Online',
+ session_link: 'Link da videochamada (apenas online)',
+ therapist_name: 'Nome do terapeuta',
+ },
+ version: 2,
+ }),
+
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.SESSION_CONFIRMATION,
+ domain: TEMPLATE_DOMAINS.SESSION,
+ subject: 'Sessão confirmada — {{session_date}} às {{session_time}}',
+ body_html: `
+ Olá, {{patient_name}} !
+ Sua sessão foi confirmada com sucesso.
+
+ Data: {{session_date}}
+ Horário: {{session_time}}
+ Modalidade: {{session_modality}}
+ {{#if session_address}}Local: {{session_address}} {{/if}}
+
+ Até lá,{{therapist_name}}
+ `,
+ body_text: `Sessão confirmada: {{session_date}} às {{session_time}} ({{session_modality}}).`,
+ variables: {
+ patient_name: 'Nome do paciente',
+ session_date: 'Data da sessão',
+ session_time: 'Horário da sessão',
+ session_modality: 'Presencial ou Online',
+ session_address: 'Endereço (apenas presencial)',
+ therapist_name: 'Nome do terapeuta',
+ },
+ version: 1,
+ }),
+
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.SESSION_CANCELLATION,
+ domain: TEMPLATE_DOMAINS.SESSION,
+ subject: 'Sessão cancelada — {{session_date}}',
+ body_html: `
+ Olá, {{patient_name}} !
+ Informamos que sua sessão do dia {{session_date}} às {{session_time}} foi cancelada.
+ {{#if cancellation_reason}}Motivo: {{cancellation_reason}}
{{/if}}
+ Entre em contato para reagendar.
+ {{therapist_name}}
+ `,
+ variables: {
+ patient_name: 'Nome do paciente',
+ session_date: 'Data cancelada',
+ session_time: 'Horário cancelado',
+ cancellation_reason: 'Motivo do cancelamento (opcional)',
+ therapist_name: 'Nome do terapeuta',
+ },
+ version: 1,
+ }),
+
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.INTAKE_RECEIVED,
+ domain: TEMPLATE_DOMAINS.INTAKE,
+ subject: 'Recebemos seu cadastro — {{clinic_name}}',
+ body_html: `
+ Olá, {{patient_name}} !
+ Recebemos seu cadastro com sucesso. Nossa equipe entrará em contato em breve para dar continuidade ao processo.
+ Obrigado pela confiança,{{clinic_name}}
+ `,
+ variables: {
+ patient_name: 'Nome do solicitante',
+ clinic_name: 'Nome da clínica ou terapeuta',
+ },
+ version: 1,
+ }),
+
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.INTAKE_APPROVED,
+ domain: TEMPLATE_DOMAINS.INTAKE,
+ subject: 'Cadastro aprovado — bem-vindo(a)!',
+ body_html: `
+ Olá, {{patient_name}} !
+ Seu cadastro foi aprovado. Você já pode acessar o portal e agendar sua primeira sessão.
+ Acessar portal →
+ Qualquer dúvida, estamos à disposição.{{therapist_name}}
+ `,
+ variables: {
+ patient_name: 'Nome do paciente',
+ therapist_name:'Nome do terapeuta',
+ portal_link: 'Link do portal do paciente',
+ },
+ version: 1,
+ }),
+
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.SCHEDULER_REQUEST_ACCEPTED,
+ domain: TEMPLATE_DOMAINS.SESSION,
+ subject: 'Sua solicitação foi aceita — {{session_date}} às {{session_time}}',
+ body_html: `
+ Olá, {{patient_name}} !
+ Sua solicitação de agendamento foi aceita.
+
+ Data: {{session_date}}
+ Horário: {{session_time}}
+ Tipo: {{session_type}}
+ Modalidade: {{session_modality}}
+
+ Até logo,{{therapist_name}}
+ `,
+ variables: {
+ patient_name: 'Nome do paciente',
+ session_date: 'Data confirmada',
+ session_time: 'Horário confirmado',
+ session_type: 'Primeira consulta / Retorno',
+ session_modality: 'Presencial ou Online',
+ therapist_name: 'Nome do terapeuta',
+ },
+ version: 1,
+ }),
+
+ createGlobalTemplate({
+ key: TEMPLATE_KEYS.SYSTEM_WELCOME,
+ domain: TEMPLATE_DOMAINS.SYSTEM,
+ subject: 'Bem-vindo(a) ao {{clinic_name}}!',
+ body_html: `
+ Olá, {{patient_name}} !
+ Seja bem-vindo(a)! Sua conta foi criada com sucesso.
+ Acessar minha área →
+ `,
+ variables: {
+ patient_name: 'Nome do paciente',
+ clinic_name: 'Nome da clínica',
+ portal_link: 'Link do portal',
+ },
+ version: 1,
+ }),
+]
+
+// ─────────────────────────────────────────────
+// RENDERER — Processamento de variáveis
+// ─────────────────────────────────────────────
+
+/**
+ * Renderiza um template substituindo {{variavel}} pelos valores fornecidos.
+ *
+ * Recursos:
+ * - {{variavel}} → substituição simples
+ * - {{#if variavel}}...{{/if}} → bloco condicional (omitido se falsy)
+ * - Variáveis ausentes são removidas silenciosamente (sem quebrar)
+ * - HTML-safe: não escapa o conteúdo (responsabilidade do caller)
+ *
+ * @param {string} template - HTML/texto com placeholders
+ * @param {Object} variables - par chave-valor das variáveis
+ * @returns {string}
+ */
+export function renderTemplate(template, variables = {}) {
+ if (!template) return ''
+
+ let result = template
+
+ // 1. Processar blocos condicionais {{#if var}}...{{/if}}
+ result = result.replace(
+ /\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
+ (_, key, content) => {
+ const value = resolveVariable(key, variables)
+ return value ? content : ''
+ }
+ )
+
+ // 2. Substituir {{variavel}} simples
+ result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
+ const value = resolveVariable(key, variables)
+ // Variável ausente: remover o placeholder silenciosamente
+ return value !== undefined && value !== null ? String(value) : ''
+ })
+
+ return result
+}
+
+/**
+ * Resolve uma variável com suporte a notação de ponto (ex: patient.name).
+ * Permite passar objetos aninhados como variáveis.
+ */
+function resolveVariable(key, variables) {
+ if (!key.includes('.')) return variables[key]
+ return key.split('.').reduce((obj, part) => obj?.[part], variables)
+}
+
+/**
+ * Renderiza subject e body de um template resolvido.
+ * Retorna um objeto pronto para envio.
+ *
+ * @param {Object} resolvedTemplate - template já resolvido (global ou tenant)
+ * @param {Object} variables - variáveis dinâmicas
+ * @returns {{ subject: string, body_html: string, body_text: string }}
+ */
+export function renderEmail(resolvedTemplate, variables = {}) {
+ return {
+ subject: renderTemplate(resolvedTemplate.subject, variables),
+ body_html: renderTemplate(resolvedTemplate.body_html, variables),
+ body_text: resolvedTemplate.body_text
+ ? renderTemplate(resolvedTemplate.body_text, variables)
+ : stripHtml(renderTemplate(resolvedTemplate.body_html, variables)),
+ }
+}
+
+/**
+ * Fallback simples para gerar body_text a partir do HTML.
+ */
+function stripHtml(html) {
+ return html
+ .replace(/ /gi, '\n')
+ .replace(/<\/p>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
+// ─────────────────────────────────────────────
+// SERVICE — Lógica de resolução
+// ─────────────────────────────────────────────
+
+/**
+ * REPOSITÓRIO — Em produção, substituir por queries Supabase reais.
+ *
+ * Exemplo de implementação real com Supabase:
+ *
+ * async function fetchGlobalTemplate(key) {
+ * const { data } = await supabase
+ * .from('email_templates_global')
+ * .select('*')
+ * .eq('key', key)
+ * .eq('is_active', true)
+ * .single()
+ * return data
+ * }
+ *
+ * async function fetchTenantTemplate(tenantId, ownerId, key) {
+ * let query = supabase
+ * .from('email_templates_tenant')
+ * .select('*')
+ * .eq('tenant_id', tenantId)
+ * .eq('template_key', key)
+ * .eq('enabled', true)
+ * // Prioriza override do terapeuta específico; fallback para tenant geral
+ * if (ownerId) {
+ * query = query.or(`owner_id.eq.${ownerId},owner_id.is.null`)
+ * .order('owner_id', { nullsFirst: false })
+ * } else {
+ * query = query.is('owner_id', null)
+ * }
+ * const { data } = await query.limit(1).single()
+ * return data
+ * }
+ */
+
+// Store in-memory de overrides para simulação
+const tenantOverridesStore = []
+
+/**
+ * Registra um override de tenant (usado para testes e simulação).
+ * Em produção, inserir diretamente na tabela email_templates_tenant.
+ */
+export function registerTenantOverride(override) {
+ tenantOverridesStore.push(createTenantTemplate(override))
+}
+
+/**
+ * RESOLUÇÃO CENTRAL — getEmailTemplate
+ *
+ * Hierarquia de resolução (da mais específica à mais genérica):
+ * 1. Override do terapeuta (owner_id + tenant_id + key + enabled)
+ * 2. Override do tenant (tenant_id + key + enabled, sem owner_id)
+ * 3. Template global (key + is_active)
+ *
+ * Merging inteligente: campos null no override herdam do global.
+ * Isso permite sobrescrever apenas o subject sem reescrever o body.
+ *
+ * @param {string} templateKey - chave do template (TEMPLATE_KEYS.*)
+ * @param {Object} [context] - { tenantId, ownerId }
+ * @returns {Object|null} - template mesclado pronto para renderização
+ */
+export async function getEmailTemplate(templateKey, context = {}) {
+ const { tenantId = null, ownerId = null } = context
+
+ // 1. Buscar global (sempre necessário como base)
+ const global = await _fetchGlobalTemplate(templateKey)
+
+ if (!global) {
+ console.warn(`[EmailTemplates] Template não encontrado: ${templateKey}`)
+ return _fallbackTemplate(templateKey)
+ }
+
+ // 2. Sem contexto de tenant → retornar global direto
+ if (!tenantId) return global
+
+ // 3. Buscar override do terapeuta específico (prioridade máxima)
+ let tenantOverride = null
+ if (ownerId) {
+ tenantOverride = await _fetchTenantTemplate(tenantId, ownerId, templateKey)
+ }
+
+ // 4. Se não encontrou override do terapeuta, buscar override do tenant
+ if (!tenantOverride) {
+ tenantOverride = await _fetchTenantTemplate(tenantId, null, templateKey)
+ }
+
+ // 5. Sem override → usar global
+ if (!tenantOverride) return global
+
+ // 6. Merge: override + global como fallback por campo
+ return _mergeTemplates(global, tenantOverride)
+}
+
+/**
+ * Merge inteligente: campos null no override herdam do global.
+ * Preserva a versão do global para rastreamento de sincronização.
+ */
+function _mergeTemplates(global, override) {
+ return {
+ ...global,
+ subject: override.subject ?? global.subject,
+ body_html: override.body_html ?? global.body_html,
+ body_text: override.body_text ?? global.body_text,
+ _source: override.subject || override.body_html ? 'tenant' : 'global',
+ _synced_version: override.synced_version,
+ _global_version: global.version,
+ // Flag para UI: indicar se tenant está desatualizado vs global
+ _needs_sync: override.synced_version !== null && override.synced_version < global.version,
+ }
+}
+
+/**
+ * Fallback de segurança — nunca retornar template vazio.
+ * Garante que o sistema nunca quebre por template faltando.
+ */
+function _fallbackTemplate(key) {
+ return createGlobalTemplate({
+ key,
+ subject: `[${key}] Notificação do sistema`,
+ body_html: `Você recebeu uma notificação do sistema.
`,
+ body_text: 'Você recebeu uma notificação do sistema.',
+ _source: 'fallback',
+ })
+}
+
+// Implementações locais (simulação do banco)
+async function _fetchGlobalTemplate(key) {
+ return globalTemplates.find(t => t.key === key && t.is_active) || null
+}
+
+async function _fetchTenantTemplate(tenantId, ownerId, key) {
+ return tenantOverridesStore.find(t =>
+ t.tenant_id === tenantId &&
+ t.owner_id === ownerId &&
+ t.template_key === key &&
+ t.enabled
+ ) || null
+}
+
+// ─────────────────────────────────────────────
+// PREVIEW — Dados mockados por domínio
+// ─────────────────────────────────────────────
+
+/**
+ * Dados de mock realistas para o contexto do sistema terapêutico.
+ * Espelham as entidades reais: patients, agenda_eventos, recurrence_rules.
+ */
+export const MOCK_DATA = {
+ // Espelha: patients + agenda_eventos
+ session: {
+ patient_name: 'Ana Clara Mendes',
+ session_date: '20/03/2026',
+ session_time: '14:00',
+ session_modality: 'Online',
+ session_link: 'https://meet.google.com/abc-defg-hij',
+ session_address: null,
+ session_type: 'Sessão de acompanhamento',
+ therapist_name: 'Dra. Beatriz Costa',
+ cancellation_reason: null,
+ },
+
+ // Espelha: patient_intake_requests + agendador_solicitacoes
+ intake: {
+ patient_name: 'Roberto Alves',
+ clinic_name: 'Espaço Terapêutico Beatriz Costa',
+ therapist_name:'Dra. Beatriz Costa',
+ portal_link: 'https://app.exemplo.com.br/portal',
+ session_date: '22/03/2026',
+ session_time: '10:00',
+ session_type: 'Primeira consulta',
+ session_modality: 'Presencial',
+ },
+
+ // Sistema
+ system: {
+ patient_name: 'Mariana Souza',
+ clinic_name: 'Clínica Harmonia',
+ portal_link: 'https://app.exemplo.com.br/portal',
+ },
+}
+
+/**
+ * Retorna dados mock adequados para um template key.
+ * Determina o domínio a partir do prefixo da key.
+ */
+function getMockDataForKey(templateKey) {
+ if (templateKey.startsWith('session') || templateKey.startsWith('scheduler')) return MOCK_DATA.session
+ if (templateKey.startsWith('intake')) return MOCK_DATA.intake
+ return MOCK_DATA.system
+}
+
+/**
+ * previewTemplate — Gera preview HTML com dados mockados.
+ *
+ * Uso principal: tela de edição de templates na UI do terapeuta.
+ *
+ * @param {string} templateKey - chave do template
+ * @param {Object} [mockData] - dados de override (sobrescreve o mock padrão)
+ * @param {Object} [context] - { tenantId, ownerId } para resolução correta
+ * @returns {Promise<{ subject, body_html, body_text, _meta }>}
+ */
+export async function previewTemplate(templateKey, mockData = {}, context = {}) {
+ const template = await getEmailTemplate(templateKey, context)
+ const variables = { ...getMockDataForKey(templateKey), ...mockData }
+ const rendered = renderEmail(template, variables)
+
+ return {
+ ...rendered,
+ _meta: {
+ key: templateKey,
+ source: template._source || 'global',
+ version: template.version,
+ needs_sync: template._needs_sync || false,
+ variables_used: Object.keys(variables),
+ },
+ }
+}
+
+/**
+ * listTemplates — Lista todos os templates disponíveis, com status por tenant.
+ *
+ * Útil para a tela de gerenciamento de templates na UI.
+ *
+ * @param {Object} [context] - { tenantId, ownerId }
+ * @returns {Promise}
+ */
+export async function listTemplates(context = {}) {
+ return Promise.all(
+ globalTemplates.map(async (global) => {
+ const resolved = await getEmailTemplate(global.key, context)
+ return {
+ key: global.key,
+ domain: global.domain,
+ channel: global.channel,
+ version: global.version,
+ is_active: global.is_active,
+ has_override: resolved._source === 'tenant',
+ needs_sync: resolved._needs_sync || false,
+ variables: global.variables,
+ }
+ })
+ )
+}
+
+// ─────────────────────────────────────────────
+// EXEMPLO DE USO — Fluxo completo
+// ─────────────────────────────────────────────
+
+async function exemploDeUso() {
+ console.log('═══ EXEMPLO 1: Template global (sem override) ═══')
+ const templateGlobal = await getEmailTemplate(TEMPLATE_KEYS.SESSION_REMINDER)
+ const emailGlobal = renderEmail(templateGlobal, MOCK_DATA.session)
+ console.log('Subject:', emailGlobal.subject)
+ console.log('Body Text:', emailGlobal.body_text)
+
+ // ─────────────────────────────────
+ console.log('\n═══ EXEMPLO 2: Override de tenant (apenas subject) ═══')
+ registerTenantOverride({
+ tenant_id: 'tenant-abc',
+ owner_id: null,
+ template_key: TEMPLATE_KEYS.SESSION_REMINDER,
+ subject: '⏰ Lembrete especial: sessão amanhã às {{session_time}} — {{patient_name}}',
+ body_html: null, // null = herda do global
+ synced_version: 2,
+ })
+
+ const templateTenant = await getEmailTemplate(
+ TEMPLATE_KEYS.SESSION_REMINDER,
+ { tenantId: 'tenant-abc' }
+ )
+ const emailTenant = renderEmail(templateTenant, MOCK_DATA.session)
+ console.log('Subject:', emailTenant.subject) // → subject customizado
+ console.log('Source:', templateTenant._source) // → 'tenant'
+
+ // ─────────────────────────────────
+ console.log('\n═══ EXEMPLO 3: Override por terapeuta específico ═══')
+ registerTenantOverride({
+ tenant_id: 'tenant-abc',
+ owner_id: 'owner-dra-beatriz',
+ template_key: TEMPLATE_KEYS.SESSION_REMINDER,
+ subject: 'Dra. Beatriz — lembrete personalizado para {{patient_name}}',
+ body_html: 'Olá {{patient_name}}, sou a Dra. Beatriz. Até amanhã! 🌿
',
+ synced_version: 2,
+ })
+
+ const templateOwner = await getEmailTemplate(
+ TEMPLATE_KEYS.SESSION_REMINDER,
+ { tenantId: 'tenant-abc', ownerId: 'owner-dra-beatriz' }
+ )
+ const emailOwner = renderEmail(templateOwner, MOCK_DATA.session)
+ console.log('Subject:', emailOwner.subject) // → override do owner
+ console.log('Source:', templateOwner._source) // → 'tenant'
+
+ // ─────────────────────────────────
+ console.log('\n═══ EXEMPLO 4: Preview com dados mockados ═══')
+ const preview = await previewTemplate(TEMPLATE_KEYS.INTAKE_RECEIVED)
+ console.log('Preview Subject:', preview.subject)
+ console.log('Preview Meta:', preview._meta)
+
+ // ─────────────────────────────────
+ console.log('\n═══ EXEMPLO 5: Listar todos os templates ═══')
+ const lista = await listTemplates({ tenantId: 'tenant-abc' })
+ lista.forEach(t => {
+ const status = t.has_override ? '[OVERRIDE]' : '[GLOBAL]'
+ const sync = t.needs_sync ? ' ⚠️ desatualizado' : ''
+ console.log(`${status} ${t.key}${sync}`)
+ })
+
+ // ─────────────────────────────────
+ console.log('\n═══ EXEMPLO 6: Template inexistente (fallback seguro) ═══')
+ const fallback = await getEmailTemplate('chave.que.nao.existe.email')
+ console.log('Fallback source:', fallback._source) // → 'fallback'
+ console.log('Nunca vazio:', !!fallback.subject) // → true
+}
+
+exemploDeUso().catch(console.error)
\ No newline at end of file
diff --git a/src/navigation/menus/saas.menu.js b/src/navigation/menus/saas.menu.js
index c4004b3..e43f9e1 100644
--- a/src/navigation/menus/saas.menu.js
+++ b/src/navigation/menus/saas.menu.js
@@ -66,8 +66,10 @@ export default function saasMenu (sessionCtx, opts = {}) {
to: '/saas/docs',
...docsBadge
},
- { label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' },
- { label: 'Carrossel Login', icon: 'pi pi-fw pi-images', to: '/saas/login-carousel' }
+ { label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' },
+ { label: 'Carrossel Login', icon: 'pi pi-fw pi-images', to: '/saas/login-carousel' },
+ { label: 'Avisos Globais', icon: 'pi pi-fw pi-megaphone', to: '/saas/global-notices' },
+ { label: 'Templates de E-mail', icon: 'pi pi-fw pi-envelope', to: '/saas/email-templates' }
]
}
]
diff --git a/src/router/routes.configs.js b/src/router/routes.configs.js
index 8da716d..cb17bc3 100644
--- a/src/router/routes.configs.js
+++ b/src/router/routes.configs.js
@@ -56,6 +56,16 @@ const configuracoesRoutes = {
path: 'convenios',
name: 'ConfiguracoesConvenios',
component: () => import('@/layout/configuracoes/ConfiguracoesConveniosPage.vue')
+ },
+ {
+ path: 'email-templates',
+ name: 'ConfiguracoesEmailTemplates',
+ component: () => import('@/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue')
+ },
+ {
+ path: 'empresa',
+ name: 'ConfiguracoesMinhaEmpresa',
+ component: () => import('@/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue')
}
]
}
diff --git a/src/router/routes.saas.js b/src/router/routes.saas.js
index 8511331..c3f8c6b 100644
--- a/src/router/routes.saas.js
+++ b/src/router/routes.saas.js
@@ -86,6 +86,18 @@ export default {
path: 'login-carousel',
name: 'saas-login-carousel',
component: () => import('@/views/pages/saas/SaasLoginCarousel.vue')
+ },
+ {
+ path: 'global-notices',
+ name: 'saas-global-notices',
+ component: () => import('@/views/pages/saas/SaasGlobalNoticesPage.vue'),
+ meta: { requiresAuth: true, saasAdmin: true }
+ },
+ {
+ path: 'email-templates',
+ name: 'saas-email-templates',
+ component: () => import('@/views/pages/saas/SaasEmailTemplatesPage.vue'),
+ meta: { requiresAuth: true, saasAdmin: true }
}
]
}
\ No newline at end of file
diff --git a/src/sql-arquivos/global_notices.sql b/src/sql-arquivos/global_notices.sql
new file mode 100644
index 0000000..68499bb
--- /dev/null
+++ b/src/sql-arquivos/global_notices.sql
@@ -0,0 +1,139 @@
+-- ================================================================
+-- GLOBAL NOTICES — Avisos Globais (Banner no topo da aplicação)
+-- ================================================================
+
+-- ── Tabela principal ────────────────────────────────────────────
+create table if not exists public.global_notices (
+ id uuid primary key default gen_random_uuid(),
+
+ -- Conteúdo
+ title text,
+ message text not null default '',
+ variant text not null default 'info'
+ check (variant in ('info', 'success', 'warning', 'error')),
+
+ -- Segmentação (arrays vazios = todos)
+ roles text[] not null default '{}',
+ contexts text[] not null default '{}',
+
+ -- Controle de tempo
+ starts_at timestamptz,
+ ends_at timestamptz,
+ is_active boolean not null default true,
+
+ -- Prioridade (maior = aparece primeiro)
+ priority int not null default 0,
+
+ -- Dismiss
+ dismissible boolean not null default true,
+ persist_dismiss boolean not null default true,
+ dismiss_scope text not null default 'device'
+ check (dismiss_scope in ('session', 'device', 'user')),
+
+ -- Regras de exibição
+ show_once boolean not null default false,
+ max_views int, -- null = ilimitado
+ cooldown_minutes int, -- null = sem cooldown
+
+ -- Versionamento (mudar version invalida dismissals anteriores)
+ version int not null default 1,
+
+ -- CTA
+ action_type text not null default 'none'
+ check (action_type in ('none', 'internal', 'external')),
+ action_label text,
+ action_url text,
+ action_route text,
+
+ -- Layout do banner
+ content_align text not null default 'left'
+ check (content_align in ('left', 'center', 'right', 'justify')),
+
+ -- Tracking (agregado)
+ views_count int not null default 0,
+ clicks_count int not null default 0,
+
+ -- Metadata
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ created_by uuid references auth.users(id) on delete set null
+);
+
+-- ── Tabela de dismissals por usuário (dismiss_scope = 'user') ───
+create table if not exists public.notice_dismissals (
+ id uuid primary key default gen_random_uuid(),
+ notice_id uuid not null references public.global_notices(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ version int not null default 1,
+ dismissed_at timestamptz not null default now(),
+ unique (notice_id, user_id)
+);
+
+-- ── Índices ─────────────────────────────────────────────────────
+create index if not exists idx_global_notices_active_priority
+ on public.global_notices (is_active, priority desc, starts_at, ends_at);
+
+create index if not exists idx_notice_dismissals_user
+ on public.notice_dismissals (user_id, notice_id);
+
+-- ── Auto updated_at ─────────────────────────────────────────────
+create or replace function public.set_updated_at()
+returns trigger language plpgsql as $$
+begin new.updated_at = now(); return new; end;
+$$;
+
+drop trigger if exists trg_global_notices_updated_at on public.global_notices;
+create trigger trg_global_notices_updated_at
+ before update on public.global_notices
+ for each row execute function public.set_updated_at();
+
+-- ── RPC: incrementar views ───────────────────────────────────────
+create or replace function public.notice_track_view(p_notice_id uuid)
+returns void language plpgsql security definer as $$
+begin
+ update public.global_notices
+ set views_count = views_count + 1
+ where id = p_notice_id;
+end;
+$$;
+
+-- ── RPC: incrementar clicks ──────────────────────────────────────
+create or replace function public.notice_track_click(p_notice_id uuid)
+returns void language plpgsql security definer as $$
+begin
+ update public.global_notices
+ set clicks_count = clicks_count + 1
+ where id = p_notice_id;
+end;
+$$;
+
+-- ── RLS ─────────────────────────────────────────────────────────
+alter table public.global_notices enable row level security;
+alter table public.notice_dismissals enable row level security;
+
+-- Qualquer usuário autenticado lê notices ativos
+drop policy if exists global_notices_select on public.global_notices;
+create policy global_notices_select
+ on public.global_notices for select
+ to authenticated
+ using (is_active = true);
+
+-- SaaS admin faz tudo
+drop policy if exists global_notices_saas_all on public.global_notices;
+create policy global_notices_saas_all
+ on public.global_notices for all
+ to authenticated
+ using (exists (select 1 from public.saas_admins where user_id = auth.uid()));
+
+-- Dismissals: cada usuário gerencia os próprios
+drop policy if exists notice_dismissals_own on public.notice_dismissals;
+create policy notice_dismissals_own
+ on public.notice_dismissals for all
+ to authenticated
+ using (user_id = auth.uid())
+ with check (user_id = auth.uid());
+
+-- ── Migração: coluna link_target ────────────────────────────
+alter table public.global_notices
+ add column if not exists link_target text not null default '_blank'
+ check (link_target in ('_blank', '_self', '_parent', '_top'));
diff --git a/src/stores/noticeStore.js b/src/stores/noticeStore.js
new file mode 100644
index 0000000..922877c
--- /dev/null
+++ b/src/stores/noticeStore.js
@@ -0,0 +1,225 @@
+// src/stores/noticeStore.js
+// Store Pinia — lógica de prioridade, filtragem, dismiss e regras de exibição
+
+import { defineStore } from 'pinia'
+import {
+ fetchActiveNotices,
+ loadUserDismissals,
+ saveDismissal,
+ trackView,
+ trackClick as svcTrackClick
+} from '@/features/notices/noticeService'
+
+// ── Storage helpers ────────────────────────────────────────────
+
+function dismissKey (id, version) {
+ return `notice_dismissed_${id}_v${version}`
+}
+
+function viewKey (id) {
+ return `notice_views_${id}`
+}
+
+function lastSeenKey (id) {
+ return `notice_last_seen_${id}`
+}
+
+function isDismissedLocally (id, version, scope) {
+ const store = scope === 'session' ? sessionStorage : localStorage
+ return store.getItem(dismissKey(id, version)) === '1'
+}
+
+function setDismissedLocally (id, version, scope) {
+ const store = scope === 'session' ? sessionStorage : localStorage
+ store.setItem(dismissKey(id, version), '1')
+}
+
+function getViewCount (id) {
+ return parseInt(localStorage.getItem(viewKey(id)) || '0', 10)
+}
+
+function incrementViewCount (id) {
+ const count = getViewCount(id) + 1
+ localStorage.setItem(viewKey(id), String(count))
+ return count
+}
+
+function getLastSeen (id) {
+ const v = localStorage.getItem(lastSeenKey(id))
+ return v ? new Date(v) : null
+}
+
+function setLastSeen (id) {
+ localStorage.setItem(lastSeenKey(id), new Date().toISOString())
+}
+
+// ── Contexto da rota → string de contexto ─────────────────────
+
+export function routeToContext (path = '') {
+ if (path.startsWith('/saas')) return 'saas'
+ if (path.startsWith('/admin')) return 'clinic'
+ if (path.startsWith('/therapist')) return 'therapist'
+ if (path.startsWith('/supervisor')) return 'supervisor'
+ if (path.startsWith('/editor')) return 'editor'
+ if (path.startsWith('/portal')) return 'portal'
+ return 'public'
+}
+
+// ── Store ──────────────────────────────────────────────────────
+
+export const useNoticeStore = defineStore('noticeStore', {
+ state: () => ({
+ notices: [], // todos os notices ativos buscados
+ activeNotice: null, // o notice sendo exibido agora
+ userDismissals: [], // dismissals do banco (scope = 'user')
+ loading: false,
+ lastFetch: null, // timestamp da última busca
+ currentRole: null, // role do usuário atual
+ currentContext: null, // contexto da rota atual
+ }),
+
+ actions: {
+
+ // ── Inicialização ──────────────────────────────────────────
+
+ async init (role, routePath) {
+ this.currentRole = role || null
+ this.currentContext = routeToContext(routePath)
+
+ // Não rebusca se buscou há menos de 5 minutos
+ const CACHE_MS = 5 * 60 * 1000
+ if (this.lastFetch && Date.now() - this.lastFetch < CACHE_MS) {
+ this._recalcActive()
+ return
+ }
+
+ await this._fetchAndApply()
+ },
+
+ async _fetchAndApply () {
+ this.loading = true
+ try {
+ const [notices, dismissals] = await Promise.all([
+ fetchActiveNotices(),
+ loadUserDismissals()
+ ])
+ this.notices = notices
+ this.userDismissals = dismissals
+ this.lastFetch = Date.now()
+ this._recalcActive()
+ } catch (e) {
+ console.warn('[NoticeStore] falha ao buscar avisos:', e?.message || e)
+ } finally {
+ this.loading = false
+ }
+ },
+
+ // ── Filtragem + prioridade ─────────────────────────────────
+
+ _recalcActive () {
+ const role = this.currentRole
+ const context = this.currentContext
+
+ const candidates = this.notices.filter(n => {
+ // 1. Segmentação por role (array vazio = todos)
+ if (n.roles?.length && role && !n.roles.includes(role)) return false
+
+ // 2. Segmentação por context (array vazio = todos)
+ if (n.contexts?.length && context && !n.contexts.includes(context)) return false
+
+ // 3. Dismiss check
+ if (this._isDismissed(n)) return false
+
+ // 4. show_once
+ if (n.show_once && getViewCount(n.id) > 0) return false
+
+ // 5. max_views
+ if (n.max_views != null && getViewCount(n.id) >= n.max_views) return false
+
+ // 6. cooldown
+ if (n.cooldown_minutes) {
+ const last = getLastSeen(n.id)
+ if (last) {
+ const diffMin = (Date.now() - last.getTime()) / 60_000
+ if (diffMin < n.cooldown_minutes) return false
+ }
+ }
+
+ return true
+ })
+
+ // Ordena por priority desc (já vem ordenado do server, mas garante)
+ candidates.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
+
+ this.activeNotice = candidates[0] || null
+ },
+
+ _isDismissed (notice) {
+ const { id, version, dismiss_scope: scope } = notice
+
+ if (scope === 'user') {
+ const entry = this.userDismissals.find(d => d.notice_id === id)
+ return !!entry && entry.version >= version
+ }
+
+ return isDismissedLocally(id, version, scope || 'device')
+ },
+
+ // ── Dismiss ────────────────────────────────────────────────
+
+ async dismiss (notice) {
+ if (!notice?.dismissible) return
+
+ const { id, version, dismiss_scope: scope, persist_dismiss: persist } = notice
+
+ if (scope === 'user' && persist) {
+ await saveDismissal(id, version)
+ this.userDismissals = [
+ ...this.userDismissals.filter(d => d.notice_id !== id),
+ { notice_id: id, version }
+ ]
+ } else if (persist) {
+ setDismissedLocally(id, version, scope || 'device')
+ } else {
+ // sem persistência: usa session como temporário
+ setDismissedLocally(id, version, 'session')
+ }
+
+ this._recalcActive()
+ },
+
+ // ── Tracking ───────────────────────────────────────────────
+
+ onView (notice) {
+ if (!notice?.id) return
+ incrementViewCount(notice.id)
+ setLastSeen(notice.id)
+ trackView(notice.id)
+ },
+
+ async onCtaClick (notice) {
+ if (!notice?.id) return
+ await svcTrackClick(notice.id)
+ },
+
+ // ── Atualiza contexto quando rota muda ─────────────────────
+
+ updateContext (routePath, role) {
+ const newCtx = routeToContext(routePath)
+ const newRole = role || this.currentRole
+
+ if (newCtx !== this.currentContext || newRole !== this.currentRole) {
+ this.currentContext = newCtx
+ this.currentRole = newRole
+ this._recalcActive()
+ }
+ },
+
+ // ── Força re-busca ─────────────────────────────────────────
+
+ async refresh () {
+ this.lastFetch = null
+ await this._fetchAndApply()
+ }
+ }
+})
diff --git a/src/utils/googleCalendarLink.js b/src/utils/googleCalendarLink.js
new file mode 100644
index 0000000..5e439ab
--- /dev/null
+++ b/src/utils/googleCalendarLink.js
@@ -0,0 +1,58 @@
+// src/utils/googleCalendarLink.js
+// Gera uma URL de "Adicionar ao Google Calendar" via link template.
+// Não requer OAuth — abre o Google Calendar do usuário com o evento pré-preenchido.
+
+/**
+ * Formata uma data + horário no padrão exigido pelo Google Calendar: YYYYMMDDTHHmmss
+ *
+ * @param {Date|string} date - Objeto Date ou string ISO (YYYY-MM-DD)
+ * @param {string} hhmm - Horário no formato "HH:MM"
+ * @returns {string} - Ex: "20260318T100000"
+ */
+export function formatGCalDate (date, hhmm = '00:00') {
+ const d = date instanceof Date ? date : new Date(date)
+ const yyyy = d.getFullYear()
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
+ const dd = String(d.getDate()).padStart(2, '0')
+ const [hh, min] = String(hhmm).split(':')
+ return `${yyyy}${mm}${dd}T${String(hh || '00').padStart(2, '0')}${String(min || '00').padStart(2, '0')}00`
+}
+
+/**
+ * Soma minutos a um horário "HH:MM" e retorna o novo "HH:MM".
+ *
+ * @param {string} hhmm - Horário base "HH:MM"
+ * @param {number} minutes - Minutos a somar
+ * @returns {string} - Ex: "11:00"
+ */
+export function addMinutesToHHMM (hhmm, minutes) {
+ const [h, m] = String(hhmm || '00:00').split(':').map(Number)
+ const total = h * 60 + m + (minutes || 0)
+ const endH = Math.floor(total / 60) % 24
+ const endM = total % 60
+ return `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
+}
+
+/**
+ * Gera a URL do Google Calendar com o evento pré-preenchido.
+ *
+ * @param {object} event
+ * @param {string} event.title - Título do evento
+ * @param {string} event.description - Descrição (aceita texto simples)
+ * @param {string} event.location - Local ou "Online"
+ * @param {string} event.start - Data/hora início no formato YYYYMMDDTHHmmss
+ * @param {string} event.end - Data/hora fim no formato YYYYMMDDTHHmmss
+ * @returns {string} URL válida para abrir no Google Calendar
+ */
+export function generateGoogleCalendarLink ({ title, description, location, start, end }) {
+ const params = new URLSearchParams({
+ action: 'TEMPLATE',
+ text: title || '',
+ dates: `${start}/${end}`,
+ details: description || '',
+ location: location || '',
+ ctz: 'America/Sao_Paulo',
+ })
+
+ return `https://www.google.com/calendar/render?${params.toString()}`
+}
diff --git a/src/views/pages/saas/SaasEmailTemplatesPage.vue b/src/views/pages/saas/SaasEmailTemplatesPage.vue
new file mode 100644
index 0000000..8f1a12c
--- /dev/null
+++ b/src/views/pages/saas/SaasEmailTemplatesPage.vue
@@ -0,0 +1,353 @@
+
+
+
+
+
+
+
+
+
+
Templates de E-mail Globais
+
+ Templates base do sistema. Tenants podem criar overrides sem alterar estes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t.key }}
+
{{ t.subject }}
+
+
+ v{{ t.version }}
+
+
+
+
+
+
+
+
+
+
+
+ Nenhum template neste domínio.
+
+
+
+
+
+
+
+
+
+ Subject *
+
+
+
+
+
+
Body HTML *
+
+
+
+
+
Inserir variável no cursor:
+
+
+
+
+
+
+ Suporta {{variavel}} e
+ {{#if variavel}}...{{/if}}
+
+
+
+
+
+
+ Body Text
+ (opcional — gerado do HTML se vazio)
+
+
+
+
+
+
+
+ Versão
+
+
+
+
+ Ativo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subject
+
{{ preview.subject }}
+
+
+
+
+
+
+
+
+
+
+
+ Preview com dados de exemplo.
+ Logo do seu perfil.
+ Sem logo — configure em Meu Perfil → Avatar .
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/pages/saas/SaasFeriadosPage.vue b/src/views/pages/saas/SaasFeriadosPage.vue
index b7c8e45..e8f9220 100644
--- a/src/views/pages/saas/SaasFeriadosPage.vue
+++ b/src/views/pages/saas/SaasFeriadosPage.vue
@@ -5,6 +5,8 @@ import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import DatePicker from 'primevue/datepicker'
+import { getFeriadosNacionais } from '@/utils/feriadosBR'
+import { createNotice, deleteNotice } from '@/features/notices/noticeService'
const toast = useToast()
@@ -54,12 +56,14 @@ async function salvar () {
saving.value = true
try {
const { data: me } = await supabase.auth.getUser()
+ const isoData = dateToISO(form.value.data)
+ const tenantId = form.value.tenant_id || null
const payload = {
owner_id: me?.user?.id || null,
- tenant_id: form.value.tenant_id || null,
+ tenant_id: tenantId,
tipo: 'municipal',
nome: form.value.nome.trim(),
- data: dateToISO(form.value.data),
+ data: isoData,
cidade: form.value.cidade.trim() || null,
estado: form.value.estado.trim() || null,
observacao: form.value.observacao.trim() || null,
@@ -68,7 +72,34 @@ async function salvar () {
const { data, error } = await supabase.from('feriados').insert(payload).select('*, tenants(name)').single()
if (error) throw error
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
- toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
+
+ // ── Auto-aviso global: somente para feriados globais (tenant_id = null) ──
+ if (!tenantId && isoData) {
+ try {
+ await createNotice({
+ title: form.value.nome.trim(),
+ message: `📅 Lembrete: ${form.value.nome.trim()} — ${fmtDate(isoData)} é feriado. Organize sua agenda com antecedência.`,
+ variant: 'info',
+ starts_at: dateMinus2(isoData),
+ ends_at: `${isoData}T23:59`,
+ is_active: true,
+ priority: 10,
+ dismissible: true,
+ persist_dismiss: true,
+ dismiss_scope: 'device',
+ content_align: 'center',
+ action_type: 'none',
+ roles: [],
+ contexts: [],
+ })
+ toast.add({ severity: 'success', summary: 'Feriado cadastrado', detail: 'Aviso global criado automaticamente.', life: 3000 })
+ } catch {
+ toast.add({ severity: 'success', summary: 'Feriado cadastrado', detail: 'Aviso global não pôde ser criado — crie manualmente em Avisos Globais.', life: 4000 })
+ }
+ } else {
+ toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
+ }
+
dlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
@@ -102,11 +133,11 @@ async function loadTenants () {
tenants.value = data || []
}
-onMounted(() => { load(); loadTenants() })
+onMounted(() => { load(); loadTenants(); carregarDeclinados() })
// ── Navegação de ano ─────────────────────────────────────────
-async function anoAnterior () { ano.value--; await load() }
-async function anoProximo () { ano.value++; await load() }
+async function anoAnterior () { ano.value--; carregarDeclinados(); await load() }
+async function anoProximo () { ano.value++; carregarDeclinados(); await load() }
// ── Helpers ──────────────────────────────────────────────────
function fmtDate (iso) {
@@ -161,6 +192,198 @@ const totalFeriados = computed(() => feriados.value.length)
const totalTenants = computed(() => new Set(feriados.value.map(f => f.tenant_id).filter(Boolean)).size)
const totalMunicipios = computed(() => new Set(feriados.value.map(f => f.cidade).filter(Boolean)).size)
+// ── Feriados Nacionais (algoritmo) ────────────────────────────
+const nacionais = computed(() => getFeriadosNacionais(ano.value))
+
+function isPassado (iso) {
+ return iso < new Date().toISOString().slice(0, 10)
+}
+
+function dateMinus2 (iso) {
+ const d = new Date(iso + 'T12:00:00')
+ d.setDate(d.getDate() - 2)
+ return `${d.toISOString().slice(0, 10)}T08:00`
+}
+
+// Datas de nacionais já publicados no DB como feriado global (tenant_id = null)
+const publicadosDatas = computed(() =>
+ new Set(feriados.value.filter(f => f.tenant_id === null).map(f => f.data))
+)
+
+// Datas marcadas como "não publicar" — persiste em localStorage por ano
+function lsKey () { return `saas_feriados_declinados_${ano.value}` }
+
+function carregarDeclinados () {
+ try {
+ const raw = localStorage.getItem(lsKey())
+ declinadosDatas.value = new Set(raw ? JSON.parse(raw) : [])
+ } catch {
+ declinadosDatas.value = new Set()
+ }
+}
+
+function salvarDeclinados () {
+ localStorage.setItem(lsKey(), JSON.stringify([...declinadosDatas.value]))
+}
+
+const declinadosDatas = ref(new Set())
+
+// Estado de cada feriado: 'published' | 'declined' | 'idle'
+function estadoNacional (iso) {
+ if (publicadosDatas.value.has(iso)) return 'published'
+ if (declinadosDatas.value.has(iso)) return 'declined'
+ return 'idle'
+}
+
+// ── Fluxo de publicação ───────────────────────────────────────
+const confirmandoNacional = ref(null) // ISO exibindo confirmação inline
+const salvandoNacional = ref(null) // ISO sendo gravado
+const dlgPublicar = ref(false) // Dialog de confirmação final
+const feriadoParaPublicar = ref(null) // Objeto feriado aguardando confirmação final
+
+function pedirConfirmacaoNacional (iso) {
+ confirmandoNacional.value = confirmandoNacional.value === iso ? null : iso
+}
+
+async function declinarNacional (feriado) {
+ const iso = feriado.data
+ const nome = feriado.nome
+
+ const s = new Set(declinadosDatas.value)
+ s.add(iso)
+ declinadosDatas.value = s
+ confirmandoNacional.value = null
+ salvarDeclinados()
+
+ // Remove avisos globais associados (mesmo título + ends_at no dia)
+ try {
+ const { data: avisos } = await supabase
+ .from('global_notices')
+ .select('id')
+ .eq('title', nome)
+ .gte('ends_at', `${iso}T00:00`)
+ .lte('ends_at', `${iso}T23:59`)
+
+ if (avisos?.length) {
+ await Promise.allSettled(avisos.map(a => deleteNotice(a.id)))
+ toast.add({ severity: 'info', summary: 'Aviso removido', detail: `Aviso global de "${nome}" excluído.`, life: 3000 })
+ }
+ } catch { /* silencioso — declinar já foi salvo */ }
+}
+
+function reverterDeclinado (iso) {
+ const s = new Set(declinadosDatas.value)
+ s.delete(iso)
+ declinadosDatas.value = s
+ salvarDeclinados()
+}
+
+function abrirDlgPublicar (feriado) {
+ feriadoParaPublicar.value = feriado
+ confirmandoNacional.value = null
+ dlgPublicar.value = true
+}
+
+// ── Despublicação ─────────────────────────────────────────────
+const dlgUnpublish = ref(false)
+const feriadoParaDespublicar = ref(null)
+const despublicando = ref(false)
+
+function abrirDlgUnpublish (feriado) {
+ // Pega o registro do banco correspondente (tenant_id=null, mesma data)
+ const registro = feriados.value.find(f => f.tenant_id === null && f.data === feriado.data)
+ feriadoParaDespublicar.value = { ...feriado, _dbId: registro?.id || null }
+ dlgUnpublish.value = true
+}
+
+async function confirmarDespublicacao () {
+ const feriado = feriadoParaDespublicar.value
+ if (!feriado) return
+ despublicando.value = true
+ dlgUnpublish.value = false
+ try {
+ // 1. Remove o feriado do banco
+ if (feriado._dbId) {
+ const { error } = await supabase.from('feriados').delete().eq('id', feriado._dbId)
+ if (error) throw error
+ feriados.value = feriados.value.filter(f => f.id !== feriado._dbId)
+ }
+
+ // 2. Remove avisos globais associados (mesmo título + ends_at no dia do feriado)
+ const { data: avisos } = await supabase
+ .from('global_notices')
+ .select('id')
+ .eq('title', feriado.nome)
+ .gte('ends_at', `${feriado.data}T00:00`)
+ .lte('ends_at', `${feriado.data}T23:59`)
+
+ await Promise.allSettled((avisos || []).map(a => deleteNotice(a.id)))
+
+ toast.add({
+ severity: 'success',
+ summary: 'Feriado despublicado',
+ detail: `${feriado.nome} removido. Aviso(s) global(is) excluído(s).`,
+ life: 3500
+ })
+ } catch (e) {
+ toast.add({ severity: 'error', summary: 'Erro ao despublicar', detail: e?.message, life: 4000 })
+ } finally {
+ despublicando.value = false
+ feriadoParaDespublicar.value = null
+ }
+}
+
+async function confirmarPublicacao () {
+ const feriado = feriadoParaPublicar.value
+ if (!feriado) return
+ salvandoNacional.value = feriado.data
+ dlgPublicar.value = false
+ try {
+ const { data: me } = await supabase.auth.getUser()
+ const { data, error } = await supabase
+ .from('feriados')
+ .insert({
+ owner_id: me?.user?.id || null,
+ tenant_id: null,
+ tipo: 'municipal',
+ nome: feriado.nome,
+ data: feriado.data,
+ bloqueia_sessoes: false, // cada tenant decide bloquear individualmente
+ })
+ .select('*, tenants(name)')
+ .single()
+ if (error) throw error
+ feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
+
+ // Auto-aviso global
+ try {
+ await createNotice({
+ title: feriado.nome,
+ message: `📅 Lembrete: ${feriado.nome} — ${fmtDate(feriado.data)} é feriado nacional. Organize sua agenda com antecedência.`,
+ variant: 'info',
+ starts_at: dateMinus2(feriado.data),
+ ends_at: `${feriado.data}T23:59`,
+ is_active: true,
+ priority: 10,
+ dismissible: true,
+ persist_dismiss: true,
+ dismiss_scope: 'device',
+ content_align: 'center',
+ action_type: 'none',
+ roles: [],
+ contexts: [],
+ })
+ } catch { /* silencioso — feriado já foi publicado */ }
+
+ toast.add({ severity: 'success', summary: 'Feriado publicado', detail: `${feriado.nome} — aviso global criado automaticamente.`, life: 3500 })
+ } catch (e) {
+ toast.add({ severity: 'error', summary: 'Erro ao publicar', detail: e?.message, life: 4000 })
+ } finally {
+ salvandoNacional.value = null
+ feriadoParaPublicar.value = null
+ }
+}
+
// ── Excluir ───────────────────────────────────────────────────
async function excluir (id) {
try {
@@ -210,7 +433,135 @@ async function excluir (id) {
-
+
+
+
+
+
+
+ Feriados Nacionais — {{ ano }}
+ (gerados automaticamente)
+
+
+
+
+
+
+
+
+ {{ fmtDate(f.data) }}
+
+
+
+
+ {{ f.nome }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
O que fazer com {{ f.nome }} ?
+
+ Publicar torna o feriado visível na agenda de todos os tenants e cria um aviso global automaticamente.
+ Não publicar mantém oculto — você pode mudar depois.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Feriados Municipais
+
@@ -303,7 +654,90 @@ async function excluir (id) {
-
+
+
+
+
+
+
+
+
+
+
{{ feriadoParaDespublicar.nome }}
+
{{ fmtDate(feriadoParaDespublicar.data) }}
+
+
+
+
+
+ O feriado será removido da agenda de todos os tenants .
+
+
+
+ O aviso global associado a este feriado será excluído automaticamente .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ feriadoParaPublicar.nome }}
+
{{ fmtDate(feriadoParaPublicar.data) }}
+
+
+
+
+
+ O feriado ficará visível na agenda de todos os tenants .
+
+
+
+ Um aviso global será criado automaticamente (você pode editar depois em Avisos Globais).
+
+
+
+ Sessões não serão bloqueadas automaticamente — cada usuário decide bloquear individualmente.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/pages/saas/SaasGlobalNoticesPage.vue b/src/views/pages/saas/SaasGlobalNoticesPage.vue
new file mode 100644
index 0000000..211b821
--- /dev/null
+++ b/src/views/pages/saas/SaasGlobalNoticesPage.vue
@@ -0,0 +1,685 @@
+
+
+
+
+
+
+
+
+
+
+
Avisos Globais
+
+ Banners no topo da aplicação segmentados por role e contexto.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nenhum aviso cadastrado.
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ notice.title }}
+
+
+
+
+ Prioridade: {{ notice.priority }}
+ Versão: {{ notice.version }}
+ De: {{ fmtDate(notice.starts_at) }}
+ Até: {{ fmtDate(notice.ends_at) }}
+ Roles: {{ notice.roles.join(', ') }}
+ Contextos: {{ notice.contexts.join(', ') }}
+
+ 👁 {{ notice.views_count }} visualizações · 🖱 {{ notice.clicks_count }} cliques
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Variante *
+
+
+
+ Título (opcional)
+
+
+
+
+
+
+
+ Mensagem * (aceita HTML simples)
+
+
+
+
+
+
+
+
+
+
+
+
+ Roles (vazio = todos)
+
+
+
+ Contextos (vazio = todos)
+
+
+
+
+
+
+
+ Exibir a partir de
+
+
+
+ Exibir até
+
+
+
+
+
+
+
+ Prioridade
+
+
+
+ Versão
+
+
+
+
+
+
+
+
Dismiss
+
+
+
+ Fechável
+
+
+
+ Persistir fechamento
+
+
+ Escopo
+
+
+
+
+
+
+
+
Regras de exibição
+
+
+
+ Exibir só 1x
+
+
+ Máx. visualizações
+
+
+
+ Cooldown (minutos)
+
+
+
+
+
+
+
+
Ação (CTA)
+
+ Tipo de ação
+
+
+
+
+
+ Label do botão
+
+
+
+ URL externa
+
+
+
+ Target
+
+
+
+
+
+
+
+ Label do botão
+
+
+
+ Rota interna
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Variante
+
+
+
+ Define a cor de fundo do banner: Info (azul), Sucesso (verde), Atenção (âmbar) ou Erro (vermelho).
+ O ícone lateral muda automaticamente conforme a variante escolhida.
+
+
+
+
+
+
+
+ Segmentação (Roles e Contextos)
+
+
+
+ Roles filtram por perfil do usuário (ex: clinic_admin, therapist). Deixar vazio exibe para todos os perfis.
+ Contextos filtram pela área da aplicação em que o usuário está (ex: clinic = /admin, therapist = /therapist). Deixar vazio exibe em qualquer área.
+
+
+
+
+
+
+
+ Controle de tempo
+
+
+
+ Exibir a partir de / Exibir até : janela de tempo em que o aviso pode aparecer. Deixar em branco = sem restrição de data.
+ O campo Ativo desativa manualmente o aviso independentemente do período configurado.
+
+
+
+
+
+
+
+ Prioridade
+
+
+
+ Quando mais de um aviso é elegível para o mesmo usuário, apenas o de maior prioridade aparece.
+ Use números inteiros — ex: 10, 50, 100. O padrão é 0 (menor prioridade).
+
+
+
+
+
+
+
+ Dismiss (fechamento)
+
+
+
+ Fechável : exibe o botão × no banner. Se desativado, o usuário não pode fechar manualmente.
+ Persistir fechamento : lembra que o usuário fechou para não reexibir.
+ Escopo do dismiss:
+ • Dispositivo — salvo em localStorage (persiste entre sessões no mesmo navegador).
+ • Sessão — salvo em sessionStorage (apaga ao fechar o navegador).
+ • Usuário — salvo no banco de dados (persiste em qualquer dispositivo do mesmo usuário).
+
+
+
+
+
+
+
+ Regras de exibição
+
+
+
+ Exibir só 1x : após a primeira visualização, o aviso não aparece mais para aquele usuário/dispositivo.
+ Máx. visualizações : limita quantas vezes o aviso pode aparecer. Ex: 3 = aparece no máximo 3 vezes.
+ Cooldown (minutos) : intervalo mínimo entre exibições. Ex: 60 = reexibe só após 1 hora desde a última vez que apareceu.
+ Útil para avisos recorrentes que não devem incomodar em cada pageload.
+
+
+
+
+
+
+
+ Versão
+
+
+
+ Ao incrementar a Versão de um aviso já publicado, todos os dismissals anteriores ficam inválidos —
+ o aviso volta a aparecer para usuários que já tinham fechado.
+ Use ao editar o conteúdo de um aviso importante que precisa ser relido.
+
+
+
+
+
+
+
+ Ação (CTA)
+
+
+
+ Nenhuma : banner só informativo, sem botão de ação.
+ Interna : botão que navega para uma rota da própria aplicação (sem abrir nova aba).
+ Selecione a rota no campo — as opções são geradas automaticamente pelo router.
+ Externa : botão que abre uma URL externa. Defina o Target (_blank = nova aba, padrão).
+
+
+
+
+
+
+
+ Rastreamento
+
+
+
+ Cada aviso acumula visualizações e cliques no CTA de forma agregada (sem identificar o usuário).
+ Os contadores são exibidos na listagem e incrementados via RPC no banco de dados.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+