Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa.

This commit is contained in:
Leonardo
2026-03-18 15:47:37 -03:00
parent d6d2fe29d1
commit 29ed349cf2
21 changed files with 5366 additions and 41 deletions

View File

@@ -1,7 +1,8 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- FIX: agendador_slots_disponiveis + agendador_dias_disponiveis
-- Usa agenda_online_slots como fonte de slots
-- Cruzamento com: agenda_eventos, recurrence_rules/exceptions, agendador_solicitacoes
-- Cruzamento com: agenda_eventos, recurrence_rules/exceptions,
-- agendador_solicitacoes, agenda_bloqueios (feriados/bloqueios)
-- Execute no Supabase SQL Editor
-- ═══════════════════════════════════════════════════════════════════════════
@@ -42,6 +43,22 @@ BEGIN
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int;
-- ── Dia inteiro bloqueado? (agenda_bloqueios sem hora) ───────────────────
-- Se sim, não há nenhum slot disponível — retorna vazio.
IF EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= p_data
AND COALESCE(b.data_fim, p_data) >= p_data
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) THEN
RETURN;
END IF;
FOR v_slot IN
SELECT s.time
FROM public.agenda_online_slots s
@@ -60,6 +77,23 @@ BEGIN
v_ocupado := true;
END IF;
-- ── Bloqueio de horário específico (agenda_bloqueios com hora) ───────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= p_data
AND COALESCE(b.data_fim, p_data) >= p_data
AND b.hora_inicio IS NOT NULL
AND b.hora_inicio < v_slot_fim
AND b.hora_fim > v_slot
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) INTO v_ocupado;
END IF;
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
@@ -73,7 +107,6 @@ BEGIN
END IF;
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
-- Loop explícito para evitar erros de tipo no cálculo do ciclo semanal
IF NOT v_ocupado THEN
FOR v_rule IN
SELECT
@@ -92,16 +125,12 @@ BEGIN
AND r.start_time::time < v_slot_fim
AND r.end_time::time > v_slot
LOOP
-- Calcula a primeira ocorrência do dia-da-semana a partir do start_date
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
-- Ocorrência válida: diff >= 0 e divisível pelo ciclo semanal
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
-- Verifica se há exceção para esta data
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type
FROM public.recurrence_exceptions ex
@@ -109,21 +138,19 @@ BEGIN
AND ex.original_date = p_data
LIMIT 1;
-- Sem exceção, ou exceção que não cancela → bloqueia o slot
IF v_ex_type IS NULL OR v_ex_type NOT IN (
'cancel_session', 'patient_missed',
'therapist_canceled', 'holiday_block',
'reschedule_session'
) THEN
v_ocupado := true;
EXIT; -- já basta uma regra que conflite
EXIT;
END IF;
END IF;
END LOOP;
END IF;
-- ── Recorrências remarcadas para este dia (reschedule → new_date = p_data)
-- ── Recorrências remarcadas para este dia ───────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1
@@ -180,6 +207,7 @@ DECLARE
v_data_fim date;
v_db_dow int;
v_tem_slot boolean;
v_bloqueado boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
@@ -197,6 +225,25 @@ BEGIN
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
-- ── Dia inteiro bloqueado? (agenda_bloqueios) ─────────────────────────
SELECT EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= v_data
AND COALESCE(b.data_fim, v_data) >= v_data
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) INTO v_bloqueado;
IF v_bloqueado THEN
v_data := v_data + 1;
CONTINUE;
END IF;
-- ── Tem slots disponíveis no dia? ─────────────────────────────────────
SELECT EXISTS (
SELECT 1 FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id

View File

@@ -1089,6 +1089,24 @@
v-tooltip.bottom="'Remover'"
@click="onDelete"
/>
<!-- Google Calendar link -->
<a
v-if="isEdit && googleCalendarUrl"
:href="googleCalendarUrl"
target="_blank"
rel="noopener noreferrer"
class="gcal-btn"
v-tooltip.top="'Abre o Google Agenda com o compromisso pré-preenchido. Em breve: sincronização automática e bidirecional com sua conta Google.'"
>
<svg class="gcal-btn__icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.7"/>
<path d="M3 9h18" stroke="currentColor" stroke-width="1.7"/>
<path d="M8 2v4M16 2v4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
<path d="M8 13h.01M12 13h.01M16 13h.01M8 17h.01M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>Google Agenda</span>
</a>
</div>
<Button
label="Salvar"
@@ -1120,6 +1138,7 @@
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink'
import { useRouter, useRoute } from 'vue-router'
import Select from 'primevue/select'
import Textarea from 'primevue/textarea'
@@ -2717,6 +2736,29 @@ function resetForm () {
}
}
// ── Google Calendar link ────────────────────────────────────────
const googleCalendarUrl = computed(() => {
const dia = form.value.dia
const hora = form.value.startTime
if (!dia || !hora) return null
const start = formatGCalDate(dia, hora)
const endHHMM = addMinutesToHHMM(hora, form.value.duracaoMin || 50)
const end = formatGCalDate(dia, endHHMM)
const paciente = form.value.paciente_nome ? `${form.value.paciente_nome}` : ''
const title = (form.value.titulo_custom?.trim() || 'Sessão') + paciente
const location = form.value.modalidade === 'online' ? 'Atendimento Online' : ''
return generateGoogleCalendarLink({
title,
description: form.value.observacoes?.trim() || '',
location,
start,
end,
})
})
function labelStatusSessao (v) {
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado' }
return map[v] || '—'
@@ -3419,4 +3461,31 @@ function statusSeverity (v) {
margin-top: .25rem;
border-top: 1px solid var(--surface-border);
}
/* ── Google Calendar button ─────────────────────── */
.gcal-btn {
display: inline-flex;
align-items: center;
gap: .45rem;
padding: .45rem .9rem;
border-radius: 6px;
border: 1px solid #4285F4;
background: transparent;
color: #4285F4;
font-size: .85rem;
font-weight: 500;
text-decoration: none;
transition: background .15s ease, color .15s ease;
cursor: pointer;
white-space: nowrap;
}
.gcal-btn:hover {
background: #4285F4;
color: #fff;
}
.gcal-btn__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,296 @@
<!-- src/features/notices/GlobalNoticeBanner.vue -->
<!-- Banner global no topo position fixed acima da topbar, empurra layout via CSS var -->
<script setup>
import { watch, onBeforeUnmount, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useNoticeStore } from '@/stores/noticeStore'
import { useTenantStore } from '@/stores/tenantStore'
const noticeStore = useNoticeStore()
const tenantStore = useTenantStore()
const router = useRouter()
const bannerEl = ref(null)
const visible = ref(false)
// ── CSS variable: empurra topbar + layout ─────────────────────
function setHeight (px) {
document.documentElement.style.setProperty('--notice-banner-height', px + 'px')
}
function measureAndSet () {
nextTick(() => {
const h = bannerEl.value?.offsetHeight || 0
setHeight(h)
})
}
// ── Reactive: notice muda → mostra/esconde ───────────────────
watch(
() => noticeStore.activeNotice,
(notice) => {
if (notice) {
visible.value = true
measureAndSet()
noticeStore.onView(notice)
} else {
visible.value = false
setHeight(0)
}
},
{ immediate: true }
)
// ── Ação CTA ─────────────────────────────────────────────────
async function handleCta (notice) {
await noticeStore.onCtaClick(notice)
if (notice.action_type === 'internal' && notice.action_route) {
router.push(notice.action_route)
} else if (notice.action_type === 'external' && notice.action_url) {
const target = notice.link_target || '_blank'
window.open(notice.action_url, target, 'noopener,noreferrer')
}
}
// ── Dismiss ──────────────────────────────────────────────────
async function dismiss () {
await noticeStore.dismiss(noticeStore.activeNotice)
}
// ── Limpa a CSS var ao desmontar ─────────────────────────────
onBeforeUnmount(() => setHeight(0))
// ── Variantes → estilos ──────────────────────────────────────
const VARIANT_STYLES = {
info: { bg: 'var(--p-blue-600, #2563eb)', icon: 'pi-info-circle' },
success: { bg: 'var(--p-green-600, #16a34a)', icon: 'pi-check-circle' },
warning: { bg: 'var(--p-amber-500, #f59e0b)', icon: 'pi-exclamation-triangle' },
error: { bg: 'var(--p-red-600, #dc2626)', icon: 'pi-times-circle' },
}
function variantStyle (variant) {
return VARIANT_STYLES[variant] || VARIANT_STYLES.info
}
const ALIGN_CLASS = {
left: 'notice-inner--left',
center: 'notice-inner--center',
right: 'notice-inner--right',
justify: 'notice-inner--justify',
}
function alignClass (align) {
return ALIGN_CLASS[align] || ALIGN_CLASS.left
}
</script>
<template>
<Transition name="notice-slide">
<div
v-if="visible && noticeStore.activeNotice"
ref="bannerEl"
class="global-notice-banner"
:style="{ background: variantStyle(noticeStore.activeNotice.variant).bg }"
role="alert"
:aria-label="noticeStore.activeNotice.title || 'Aviso'"
>
<!-- notice-inner controla o max-width/padding; o alinhamento fica no notice-group-wrap -->
<div class="notice-inner" :class="alignClass(noticeStore.activeNotice.content_align)">
<!-- Grupo alinhável: ícone + texto + cta -->
<div class="notice-group">
<i
class="notice-icon pi"
:class="variantStyle(noticeStore.activeNotice.variant).icon"
aria-hidden="true"
/>
<div class="notice-body">
<span v-if="noticeStore.activeNotice.title" class="notice-title">
{{ noticeStore.activeNotice.title }}
</span>
<!-- eslint-disable vue/no-v-html -->
<span class="notice-message" v-html="noticeStore.activeNotice.message" />
</div>
<button
v-if="noticeStore.activeNotice.action_type !== 'none' && noticeStore.activeNotice.action_label"
class="notice-cta"
type="button"
@click="handleCta(noticeStore.activeNotice)"
>
{{ noticeStore.activeNotice.action_label }}
<i
v-if="noticeStore.activeNotice.action_type === 'external'"
class="pi pi-external-link notice-cta__ext"
/>
</button>
</div>
<!-- Fechar: sempre absoluto no canto direito -->
<button
v-if="noticeStore.activeNotice.dismissible"
class="notice-close"
type="button"
aria-label="Fechar aviso"
@click="dismiss"
>
<i class="pi pi-times" />
</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.global-notice-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 101; /* acima da topbar (z-index 100) */
min-height: 44px;
display: flex;
align-items: stretch;
}
.notice-inner {
position: relative; /* âncora do botão fechar absoluto */
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 0 2.5rem 0 1rem; /* padding-right reserva espaço pro fechar */
display: flex;
align-items: center;
min-height: 44px;
}
/* ── Grupo alinhável (ícone + texto + cta) ───────────────── */
.notice-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 6px 0;
}
/* ── Alinhamento: muda só o .notice-group dentro do .notice-inner ── */
.notice-inner--left { justify-content: flex-start; }
.notice-inner--center { justify-content: center; }
.notice-inner--right { justify-content: flex-end; }
.notice-inner--justify { justify-content: flex-start; }
/* justify: grupo expande para preencher toda a largura */
.notice-inner--justify .notice-group { flex: 1; }
.notice-inner--justify .notice-body { flex: 1; }
/* ── Ícone ───────────────────────────────────────────────── */
.notice-icon {
font-size: 1rem;
color: rgba(255, 255, 255, 0.92);
flex-shrink: 0;
}
/* ── Corpo de texto ──────────────────────────────────────── */
.notice-body {
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: #fff;
line-height: 1.45;
}
.notice-title {
font-weight: 700;
white-space: nowrap;
}
.notice-message {
opacity: 0.92;
}
/* Links dentro do HTML do message */
.notice-message :deep(a) {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
/* ── CTA button ──────────────────────────────────────────── */
.notice-cta {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 14px;
border-radius: 6px;
border: 1.5px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
}
.notice-cta:hover {
background: rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.8);
}
.notice-cta__ext {
font-size: 0.65rem;
opacity: 0.8;
}
/* ── Fechar: sempre ancorado no canto direito ────────────── */
.notice-close {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.75);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.8rem;
transition: background 0.15s, color 0.15s;
}
.notice-close:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* ── Transição slide-down ────────────────────────────────── */
.notice-slide-enter-active {
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease;
}
.notice-slide-leave-active {
transition: transform 0.22s ease, opacity 0.2s ease;
}
.notice-slide-enter-from,
.notice-slide-leave-to {
transform: translateY(-100%);
opacity: 0;
}
/* ── Dark mode: sem mudança — cores via variante ─────────── */
</style>

View File

@@ -0,0 +1,116 @@
// src/features/notices/noticeService.js
// Serviço central de acesso ao Supabase para Global Notices
import { supabase } from '@/lib/supabase/client'
// ── Leitura ────────────────────────────────────────────────────
/**
* Busca todos os notices ativos e dentro do período de exibição.
* A filtragem por role/context é feita no cliente (noticeStore)
* para evitar lógica de array no PostgREST.
*/
export async function fetchActiveNotices () {
const now = new Date().toISOString()
const { data, error } = await supabase
.from('global_notices')
.select('*')
.eq('is_active', true)
.or(`starts_at.is.null,starts_at.lte.${now}`)
.or(`ends_at.is.null,ends_at.gte.${now}`)
.order('priority', { ascending: false })
if (error) throw error
return data || []
}
/**
* Busca todos os notices (sem filtro de ativo) — para o painel admin.
*/
export async function fetchAllNotices () {
const { data, error } = await supabase
.from('global_notices')
.select('*')
.order('priority', { ascending: false })
.order('created_at', { ascending: false })
if (error) throw error
return data || []
}
// ── Tracking ───────────────────────────────────────────────────
export async function trackView (noticeId) {
try {
await supabase.rpc('notice_track_view', { p_notice_id: noticeId })
} catch { /* silencioso — tracking não deve quebrar o app */ }
}
export async function trackClick (noticeId) {
try {
await supabase.rpc('notice_track_click', { p_notice_id: noticeId })
} catch {}
}
// ── Dismiss scope: 'user' (persiste no banco) ─────────────────
export async function saveDismissal (noticeId, version) {
const { data: { user } } = await supabase.auth.getUser()
if (!user?.id) return
await supabase
.from('notice_dismissals')
.upsert({ notice_id: noticeId, user_id: user.id, version }, { onConflict: 'notice_id,user_id' })
}
export async function loadUserDismissals () {
const { data: { user } } = await supabase.auth.getUser()
if (!user?.id) return []
const { data } = await supabase
.from('notice_dismissals')
.select('notice_id, version')
.eq('user_id', user.id)
return data || []
}
// ── Admin CRUD ─────────────────────────────────────────────────
export async function createNotice (payload) {
const { data: { user } } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('global_notices')
.insert({ ...payload, created_by: user?.id })
.select()
.single()
if (error) throw error
return data
}
export async function updateNotice (id, payload) {
const { data, error } = await supabase
.from('global_notices')
.update(payload)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
}
export async function deleteNotice (id) {
const { error } = await supabase
.from('global_notices')
.delete()
.eq('id', id)
if (error) throw error
}
export async function toggleNoticeActive (id, isActive) {
return updateNotice(id, { is_active: isActive })
}

View File

@@ -10,7 +10,13 @@ import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailSidebar from './AppRailSidebar.vue'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
import GlobalNoticeBanner from '@/features/notices/GlobalNoticeBanner.vue'
import { useNoticeStore } from '@/stores/noticeStore'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
@@ -21,19 +27,23 @@ const ajudaPushStyle = computed(() => ({
paddingRight: drawerOpen.value ? '420px' : '0'
}))
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const route = useRoute()
const route = useRoute()
const noticeStore = useNoticeStore()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
const layoutArea = computed(() => route.meta?.area || null)
provide('layoutArea', layoutArea)
const tenantStore = useTenantStore()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const tf = useTenantFeaturesStore()
const tf = useTenantFeaturesStore()
// ── Atualiza contexto dos notices ao mudar de rota ────────────
watch(
() => route.path,
(path) => noticeStore.updateContext(path, tenantStore.role),
{ immediate: false }
)
const containerClass = computed(() => {
return {
@@ -86,6 +96,7 @@ watch(() => route.path, (path) => fetchDocsForPath(path), { immediate: true })
onMounted(() => {
window.addEventListener('app:session-refreshed', onSessionRefreshed)
noticeStore.init(tenantStore.role, route.path)
})
onBeforeUnmount(() => {
@@ -146,6 +157,7 @@ onBeforeUnmount(() => {
<!-- Global fora de todos os branches, persiste em qualquer layout/rota -->
<SupportDebugBanner />
<GlobalNoticeBanner />
</template>
<style>
@@ -154,14 +166,24 @@ onBeforeUnmount(() => {
para sobrescrever o tema PrimeVue/Sakai
────────────────────────────────────────────── */
/* ── Global Notice Banner: variável de altura ─────────────
Definida aqui como fallback; o componente altera via JS */
:root {
--notice-banner-height: 0px;
}
/* ── Topbar: desce pelo banner ─────────────────────────── */
.rail-topbar {
top: var(--notice-banner-height) !important;
}
/* ── Sidebar — sempre abaixo da topbar fixed (56px) ────────
z-index: 999 para flutuar sobre o conteúdo em overlay.
Topbar (z-index 1000) fica sempre acessível acima da sidebar. */
Desce pelo banner também. */
.layout-sidebar {
position: fixed !important;
top: 56px !important;
top: calc(56px + var(--notice-banner-height)) !important;
left: 0 !important;
height: calc(100vh - 56px) !important;
height: calc(100vh - 56px - var(--notice-banner-height)) !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: 2px 0 6px rgba(0,0,0,.06) !important;
@@ -181,7 +203,7 @@ onBeforeUnmount(() => {
.layout-main-container {
margin-left: 20rem !important;
padding-left: 0 !important;
padding-top: 56px !important;
padding-top: calc(56px + var(--notice-banner-height)) !important;
}
.layout-overlay .layout-main-container,
.layout-static-inactive .layout-main-container {
@@ -203,11 +225,14 @@ onBeforeUnmount(() => {
</style>
<style scoped>
/* ─── Layout Rail (inalterado) ────────────────── */
/* ─── Layout Rail ─────────────────────────────── */
.l2-root {
position: fixed;
top: var(--notice-banner-height, 0px); /* desce pelo banner */
left: 0;
right: 0;
bottom: 0;
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--surface-ground);
}
@@ -217,9 +242,9 @@ onBeforeUnmount(() => {
min-width: 0;
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
overflow: hidden;
padding-top: 56px; /* compensa a topbar fixed */
padding-top: 56px; /* compensa topbar — banner já absorvido pelo l2-root */
}
.l2-content {

View File

@@ -1,6 +1,6 @@
<!-- src/layout/AppTopbar.vue -->
<script setup>
import { computed, ref, onMounted, provide, nextTick } from 'vue'
import { computed, ref, onMounted, provide, nextTick, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useLayout } from '@/layout/composables/layout'
@@ -31,6 +31,7 @@ import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersist
import { applyThemeEngine } from '@/theme/theme.options'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { fetchAllNotices } from '@/features/notices/noticeService'
const toast = useToast()
const entitlementsStore = useEntitlementsStore()
@@ -472,6 +473,21 @@ async function changePlanTo (newPlanId, newPlanKey, target) {
}
}
/* ----------------------------
SaaS — indicador de avisos ativos
----------------------------- */
const isSaasArea = computed(() => String(route.path || '').startsWith('/saas'))
const saasActiveCount = ref(0)
async function loadSaasNoticeCount () {
try {
const all = await fetchAllNotices()
saasActiveCount.value = all.filter(n => n.is_active).length
} catch { /* silencioso */ }
}
watch(isSaasArea, (is) => { if (is) loadSaasNoticeCount() }, { immediate: true })
/* ----------------------------
Logout
----------------------------- */
@@ -536,6 +552,20 @@ onMounted(async () => {
<span>Agência PSI</span>
</router-link>
<!-- Indicador de avisos globais ativos SaaS only -->
<router-link
v-if="isSaasArea && saasActiveCount > 0"
to="/saas/global-notices"
class="topbar-notice-chip ml-3"
:title="`${saasActiveCount} aviso(s) global(is) ativo(s)`"
>
<span class="topbar-notice-chip__dot" />
<i class="pi pi-megaphone topbar-notice-chip__icon" />
<span class="topbar-notice-chip__label">
{{ saasActiveCount }} aviso{{ saasActiveCount !== 1 ? 's' : '' }} ativo{{ saasActiveCount !== 1 ? 's' : '' }}
</span>
</router-link>
<!-- Pills: visíveis apenas em > 1200px -->
<div class="topbar-ctx-row ml-2">
<span
@@ -661,7 +691,7 @@ onMounted(async () => {
<style scoped>
.rail-topbar {
position: fixed;
top: 0;
top: var(--notice-banner-height, 0px);
left: 0;
right: 0;
height: 56px;
@@ -726,6 +756,52 @@ onMounted(async () => {
z-index: 200;
}
/* ── Chip de avisos ativos (SaaS) ────────────────────────── */
.topbar-notice-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.28rem 0.75rem 0.28rem 0.55rem;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
animation: topbar-notice-pulse 2s ease-in-out infinite;
transition: filter 0.15s;
}
.topbar-notice-chip:hover {
filter: brightness(1.1);
}
.topbar-notice-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #fff;
flex-shrink: 0;
animation: topbar-dot-blink 1.4s ease-in-out infinite;
}
.topbar-notice-chip__icon {
font-size: 0.8rem;
}
@keyframes topbar-notice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
50% { box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); }
}
@keyframes topbar-dot-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* Mobile: oculta o texto, mantém ícone + dot */
@media (max-width: 600px) {
.topbar-notice-chip__label { display: none; }
.topbar-notice-chip { padding: 0.28rem 0.5rem; }
}
/* Badge de notificações */
.rail-topbar__notification-badge {
position: absolute;

View File

@@ -77,6 +77,22 @@ const secoes = [
to: '/configuracoes/convenios',
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
},
{
key: 'empresa',
label: 'Minha Empresa',
desc: 'CNPJ, endereço, logomarca e redes sociais.',
icon: 'pi pi-building',
to: '/configuracoes/empresa',
tags: ['CNPJ', 'Endereço', 'Logo']
},
{
key: 'email-templates',
label: 'Templates de E-mail',
desc: 'Personalize os e-mails enviados aos pacientes.',
icon: 'pi pi-envelope',
to: '/configuracoes/email-templates',
tags: ['E-mail', 'Notificações', 'Personalizar']
},
]
const activeTo = computed(() => {
@@ -156,7 +172,7 @@ onBeforeUnmount(() => { _observer?.disconnect() })
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Sidebar: lista de seções (oculto no mobile temos os cards acima) -->
<div class="hidden xl:flex flex-col gap-1 w-[260px] shrink-0">
<div class="hidden xl:flex flex-col gap-1 w-[260px] shrink-0 cfg-sidebar-col">
<div class="cfg-sidebar-wrap">
<div class="cfg-sidebar-head">
<i class="pi pi-cog text-xs opacity-60" />
@@ -278,6 +294,13 @@ onBeforeUnmount(() => { _observer?.disconnect() })
color: var(--primary-color, #6366f1);
}
/* ── Sidebar col sticky ───────────────────────────────── */
.cfg-sidebar-col {
position: sticky;
top: calc(var(--layout-sticky-top, 56px) + 58px);
align-self: flex-start;
}
/* ── Sidebar nav ──────────────────────────────────────── */
.cfg-sidebar-wrap {
border: 1px solid var(--surface-border);

View File

@@ -0,0 +1,850 @@
<!-- src/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Editor from 'primevue/editor'
import { supabase } from '@/lib/supabase/client'
import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService'
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
const toast = useToast()
const confirm = useConfirm()
const router = useRouter()
// ── Contexto ──────────────────────────────────────────────────
const tenantId = ref(null)
const profileLogoUrl = ref(null)
async function loadUser() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
tenantId.value = user.id
const { data } = await supabase
.from('company_profiles')
.select('logo_url')
.eq('tenant_id', user.id)
.maybeSingle()
profileLogoUrl.value = data?.logo_url || null
}
// ── Layout config global (header/footer do tenant) ────────────
const layoutConfigId = ref(null)
const layoutConfig = ref({ header: defaultSection(), footer: defaultSection() })
function defaultSection() {
return { enabled: false, content: '', layout: null }
}
async function loadLayoutConfig() {
if (!tenantId.value) return
const { data } = await supabase
.from('email_layout_config')
.select('*')
.eq('tenant_id', tenantId.value)
.maybeSingle()
if (data) {
layoutConfigId.value = data.id
layoutConfig.value.header = { ...defaultSection(), ...(data.header_config || {}) }
layoutConfig.value.footer = { ...defaultSection(), ...(data.footer_config || {}) }
}
}
// ── Templates globais + overrides ─────────────────────────────
const globals = ref([])
const overrides = ref([])
const loading = ref(false)
const filterDomain = ref(null)
async function load() {
if (!tenantId.value) return
loading.value = true
try {
const [{ data: gData, error: gErr }, { data: oData, error: oErr }] = await Promise.all([
supabase.from('email_templates_global').select('*').eq('is_active', true).order('domain').order('key'),
supabase.from('email_templates_tenant').select('*').eq('tenant_id', tenantId.value).is('owner_id', null),
])
if (gErr) throw gErr
if (oErr) throw oErr
globals.value = gData || []
overrides.value = oData || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
}
const overrideMap = computed(() => {
const map = {}
for (const o of overrides.value) map[o.template_key] = o
return map
})
const filtered = computed(() => {
const list = filterDomain.value
? globals.value.filter(t => t.domain === filterDomain.value)
: globals.value
return list.map(g => {
const ov = overrideMap.value[g.key] || null
const mock = _mockForDomain(g.domain)
return {
...g,
override: ov,
has_override: !!ov,
needs_sync: ov ? (ov.synced_version !== null && ov.synced_version < g.version) : false,
rendered_subject: renderTemplate(ov?.subject ?? g.subject, mock),
rendered_body_snippet: _bodySnippet(ov?.body_html ?? g.body_html, mock),
}
})
})
function _bodySnippet(html, mock) {
const rendered = renderTemplate(html || '', mock)
const text = rendered
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
return text.length > 100 ? text.slice(0, 100) + '…' : text
}
function _mockForDomain(domain) {
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
return { ...MOCK_DATA.system }
}
const DOMAIN_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem',value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema',value: TEMPLATE_DOMAINS.SYSTEM },
]
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
// ── Dialog layout (header/footer global) ──────────────────────
const layoutDlg = ref({ open: false, saving: false })
const layoutForm = ref({ header: defaultSection(), footer: defaultSection() })
const headerEditorRef = ref(null)
const footerEditorRef = ref(null)
const LAYOUT_OPTIONS = [
{ value: 'logo-left', label: 'Logo à esquerda' },
{ value: 'logo-right', label: 'Logo à direita' },
{ value: 'logo-center', label: 'Logo centralizada' },
]
const TEXT_OPTIONS = [
{ value: 'text-left', label: 'Texto à esquerda' },
{ value: 'text-center', label: 'Texto centralizado' },
{ value: 'text-right', label: 'Texto à direita' },
]
function openLayoutDlg() {
layoutForm.value = {
header: { ...defaultSection(), ...layoutConfig.value.header },
footer: { ...defaultSection(), ...layoutConfig.value.footer },
}
layoutDlg.value = { open: true, saving: false }
}
function selectLayout(which, type) {
layoutForm.value[which].layout = type
}
const headerLayoutPreview = computed(() =>
generateLayoutSection(layoutForm.value.header, profileLogoUrl.value, true)
)
const footerLayoutPreview = computed(() =>
generateLayoutSection(layoutForm.value.footer, profileLogoUrl.value, false)
)
async function saveLayout() {
if (!tenantId.value) return
layoutDlg.value.saving = true
try {
const payload = {
tenant_id: tenantId.value,
header_config: layoutForm.value.header,
footer_config: layoutForm.value.footer,
}
if (layoutConfigId.value) {
const { error } = await supabase.from('email_layout_config').update(payload).eq('id', layoutConfigId.value)
if (error) throw error
} else {
const { data, error } = await supabase.from('email_layout_config').insert(payload).select('id').single()
if (error) throw error
layoutConfigId.value = data.id
}
layoutConfig.value.header = { ...layoutForm.value.header }
layoutConfig.value.footer = { ...layoutForm.value.footer }
toast.add({ severity: 'success', summary: 'Layout salvo', life: 3000 })
layoutDlg.value.open = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
layoutDlg.value.saving = false
}
}
// ── Dialog edição de template ──────────────────────────────────
const dlg = ref({ open: false, saving: false, mode: 'create', globalTemplate: null })
const form = ref({})
const editorRef = ref(null)
function openEdit(row) {
const ov = row.override
form.value = {
template_key: row.key,
use_custom_subject: !!ov?.subject,
use_custom_body: !!ov?.body_html,
subject: ov?.subject ?? row.subject,
body_html: ov?.body_html ?? row.body_html,
body_text: ov?.body_text ?? '',
enabled: ov?.enabled ?? true,
synced_version: row.version,
variables: row.variables || {},
}
dlg.value = { open: true, saving: false, mode: ov ? 'edit' : 'create', globalTemplate: row }
}
function closeDlg() { dlg.value.open = false }
const formVariables = computed(() => {
const keys = Object.keys(form.value.variables || {})
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
return keys
})
function insertVar(varName) {
const snippet = `{{${varName}}}`
const quill = editorRef.value?.quill
if (!quill) { form.value.body_html = (form.value.body_html || '') + snippet; return }
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength() - 1
quill.insertText(index, snippet, 'user')
quill.setSelection(index + snippet.length, 0)
}
async function save() {
if (!tenantId.value) return
if (form.value.use_custom_subject && !form.value.subject?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject não pode ser vazio', life: 3000 })
return
}
dlg.value.saving = true
try {
const payload = {
tenant_id: tenantId.value,
owner_id: null,
template_key: form.value.template_key,
subject: form.value.use_custom_subject ? form.value.subject : null,
body_html: form.value.use_custom_body ? form.value.body_html : null,
body_text: form.value.use_custom_body && form.value.body_text ? form.value.body_text : null,
enabled: form.value.enabled,
synced_version: form.value.synced_version,
}
if (dlg.value.mode === 'create') {
const { error } = await supabase.from('email_templates_tenant').insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Personalização salva', life: 3000 })
} else {
const { error } = await supabase.from('email_templates_tenant').update(payload).eq('id', overrideMap.value[form.value.template_key].id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Personalização salva', life: 3000 })
}
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
}
function confirmRevert(row) {
confirm.require({
message: `Reverter "${row.key}" para o template global?`,
header: 'Remover personalização',
icon: 'pi pi-undo',
acceptClass: 'p-button-danger',
accept: async () => {
try {
const { error } = await supabase.from('email_templates_tenant').delete().eq('id', row.override.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Revertido para o padrão', life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
})
}
// ── Dialog preview ─────────────────────────────────────────────
const preview = ref({ open: false, subject: '', body_html: '', key: '', source: '' })
function openPreview(row) {
const resolved = {
...row,
subject: row.override?.subject ?? row.subject,
body_html: row.override?.body_html ?? row.body_html,
body_text: row.override?.body_text ?? row.body_text,
}
const mock = {
..._mockForDomain(row.domain),
clinic_logo_url: profileLogoUrl.value || null,
}
const rendered = renderEmail(resolved, mock, {
headerConfig: layoutConfig.value.header,
footerConfig: layoutConfig.value.footer,
logoUrl: profileLogoUrl.value,
})
preview.value = { open: true, ...rendered, key: row.key, source: row.has_override ? 'personalizado' : 'global' }
}
const layoutActive = computed(() =>
layoutConfig.value.header.enabled || layoutConfig.value.footer.enabled
)
onMounted(async () => {
await loadUser()
await Promise.all([load(), loadLayoutConfig()])
})
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-envelope" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Templates de E-mail</div>
<div class="cfg-subheader__sub">Personalize os e-mails enviados aos seus pacientes</div>
</div>
<div class="cfg-subheader__actions">
<Button
label="Personalizar +"
icon="pi pi-palette"
size="small"
class="rounded-full"
@click="openLayoutDlg"
/>
</div>
</div>
<!-- Filtro -->
<div class="flex gap-2 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="row in filtered"
:key="row.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
>
<Tag
:value="DOMAIN_LABEL[row.domain] ?? row.domain"
:severity="DOMAIN_SEVERITY[row.domain] ?? 'secondary'"
class="text-[0.7rem] shrink-0 self-start mt-0.5"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-0.5">
<span class="font-mono text-xs text-[var(--text-color-secondary)]">{{ row.key }}</span>
<Tag v-if="row.has_override" value="Personalizado" severity="success" class="text-[0.65rem]" />
<Tag v-if="row.needs_sync" value="Desatualizado" severity="warning" class="text-[0.65rem]" />
</div>
<div class="text-sm font-medium truncate">{{ row.rendered_subject }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate mt-0.5">{{ row.rendered_body_snippet }}</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(row)" />
<Button
:icon="row.has_override ? 'pi pi-pencil' : 'pi pi-sliders-h'"
text rounded size="small"
:title="row.has_override ? 'Editar personalização' : 'Personalizar'"
@click="openEdit(row)"
/>
<Button
v-if="row.has_override"
icon="pi pi-undo" text rounded size="small" severity="danger"
title="Reverter para padrão"
@click="confirmRevert(row)"
/>
</div>
</div>
</div>
<!-- Dialog Layout Global (Header/Footer) -->
<Dialog
v-model:visible="layoutDlg.open"
header="Personalizar Layout do E-mail"
modal
:style="{ width: '820px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Banner: trocar logo -->
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-image text-[var(--text-color-secondary)] text-sm opacity-60" />
<span class="text-sm flex-1 text-[var(--text-color-secondary)]">
Para trocar sua logo, acesse <strong>Minha Empresa</strong>.
</span>
<Button
label="Minha Empresa"
icon="pi pi-building"
size="small"
severity="secondary"
outlined
class="shrink-0"
@click="router.push('/configuracoes/empresa')"
/>
</div>
<!-- HEADER -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<div class="flex items-center gap-2">
<i class="pi pi-arrow-up text-xs opacity-50" />
<span class="text-sm font-semibold">Header</span>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="layoutForm.header.enabled" inputId="sw-header" />
<label for="sw-header" class="text-xs cursor-pointer select-none">Ativo</label>
</div>
</div>
<div v-if="layoutForm.header.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout -->
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold">Com logo</p>
<div class="flex gap-2">
<button
v-for="opt in LAYOUT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.header.layout === opt.value }"
@click="selectLayout('header', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'logo-left'">
<div class="lc-logo" /><div class="lc-spacer" /><div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div><div class="lc-spacer" /><div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center"><div class="lc-logo" /><div class="lc-line" style="width:70%;margin-top:5px;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button
v-for="opt in TEXT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.header.layout === opt.value }"
@click="selectLayout('header', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center"><div class="lc-line" style="width:85%;" /><div class="lc-line short" style="width:55%;align-self:center;" /></div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right"><div class="lc-line" /><div class="lc-line short" style="align-self:flex-end;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="headerEditorRef" v-model="layoutForm.header.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Preview -->
<div v-if="headerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="headerLayoutPreview" />
</div>
</div>
</div>
<!-- FOOTER -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<div class="flex items-center gap-2">
<i class="pi pi-arrow-down text-xs opacity-50" />
<span class="text-sm font-semibold">Rodapé</span>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="layoutForm.footer.enabled" inputId="sw-footer" />
<label for="sw-footer" class="text-xs cursor-pointer select-none">Ativo</label>
</div>
</div>
<div v-if="layoutForm.footer.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout -->
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold">Com logo</p>
<div class="flex gap-2">
<button
v-for="opt in LAYOUT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }"
@click="selectLayout('footer', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'logo-left'">
<div class="lc-logo" /><div class="lc-spacer" /><div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div><div class="lc-spacer" /><div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center"><div class="lc-logo" /><div class="lc-line" style="width:70%;margin-top:5px;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button
v-for="opt in TEXT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }"
@click="selectLayout('footer', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center"><div class="lc-line" style="width:85%;" /><div class="lc-line short" style="width:55%;align-self:center;" /></div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right"><div class="lc-line" /><div class="lc-line short" style="align-self:flex-end;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="footerEditorRef" v-model="layoutForm.footer.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Preview -->
<div v-if="footerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="footerLayoutPreview" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="layoutDlg.open = false" :disabled="layoutDlg.saving" />
<Button label="Salvar layout" icon="pi pi-check" :loading="layoutDlg.saving" @click="saveLayout" />
</template>
</Dialog>
<!-- Dialog Edição de Template -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? `Personalizar — ${form.template_key}` : `Editar — ${form.template_key}`"
modal
:style="{ width: '840px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Subject -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<span class="text-sm font-semibold">Subject</span>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.use_custom_subject" inputId="sw-custom-subject" />
<label for="sw-custom-subject" class="text-xs cursor-pointer select-none">Personalizar</label>
</div>
</div>
<div class="px-4 py-3">
<InputText v-if="form.use_custom_subject" v-model="form.subject" class="w-full" />
<p v-else class="text-xs text-[var(--text-color-secondary)] m-0 italic">
Usando padrão: "{{ dlg.globalTemplate?.subject }}"
</p>
</div>
</div>
<!-- Corpo -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<span class="text-sm font-semibold">Corpo do e-mail</span>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.use_custom_body" inputId="sw-custom-body" />
<label for="sw-custom-body" class="text-xs cursor-pointer select-none">Personalizar</label>
</div>
</div>
<div class="px-4 py-3 flex flex-col gap-3">
<template v-if="form.use_custom_body">
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height:200px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<select class="ql-header">
<option value="1">Título</option>
<option value="2">Subtítulo</option>
<option selected>Normal</option>
</select>
</span>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-align" />
<select class="ql-color" />
<select class="ql-background" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" type="button" />
<button class="ql-list" value="bullet" type="button" />
</span>
<span class="ql-formats">
<button class="ql-link" type="button" />
<button class="ql-clean" type="button" />
</span>
</template>
</Editor>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables" :key="v"
:label="`{{${v}}}`"
size="small" severity="secondary" outlined
class="font-mono !text-[0.68rem] !py-1 !px-2"
@click="insertVar(v)"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold">Versão texto <span class="font-normal opacity-60">(gerado do HTML se vazio)</span></label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
</div>
</template>
<p v-else class="text-xs text-[var(--text-color-secondary)] m-0 italic">
Usando o corpo padrão do sistema.
</p>
</div>
</div>
<!-- Override ativo -->
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.enabled" inputId="sw-enabled" />
<label for="sw-enabled" class="text-sm cursor-pointer select-none">Override ativo</label>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button :label="dlg.mode === 'create' ? 'Salvar personalização' : 'Salvar'" icon="pi pi-check" :loading="dlg.saving" @click="save" />
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog
v-model:visible="preview.open"
:header="`Preview — ${preview.key}`"
modal
:style="{ width: '680px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="flex justify-center">
<Tag
:value="preview.source === 'personalizado' ? 'Usando sua personalização' : 'Usando template global'"
:severity="preview.source === 'personalizado' ? 'success' : 'secondary'"
/>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800 text-sm leading-relaxed">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="preview.body_html" />
</div>
<p v-if="!profileLogoUrl" class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
Adicione um avatar no seu perfil para exibir o logo no header/rodapé.
</p>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Layout cards ───────────────────────────────────────── */
.layout-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 8px;
border: 1.5px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.layout-card:hover {
border-color: color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
background: color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card));
}
.layout-card--active {
border-color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
}
.layout-card__thumb {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
height: 38px;
border: 1px solid #e5e7eb;
border-radius: 5px;
padding: 6px;
background: #f9fafb;
}
.layout-card__label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
text-align: center;
line-height: 1.3;
}
.layout-card--active .layout-card__label {
color: var(--primary-color,#6366f1);
}
/* Elementos internos dos cards */
.lc-logo {
width: 22px;
height: 22px;
border-radius: 3px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 35%, #e5e7eb);
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
}
.lc-spacer { flex: 1; min-width: 4px; }
.lc-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.lc-lines--center { align-items: center; }
.lc-lines--right { align-items: flex-end; }
.lc-line { height: 3px; background: #d1d5db; border-radius: 2px; }
.lc-line.short { width: 60%; }
.lc-center {
display: flex; flex-direction: column; align-items: center;
width: 100%; gap: 2px;
}
.lc-center .lc-logo { width: 18px; height: 18px; }
.lc-center .lc-line { width: 100%; }
/* Esconde botão de imagem do Quill em todos os editores desta página */
:deep(.ql-image) { display: none !important; }
</style>

View File

@@ -0,0 +1,706 @@
<!-- src/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
// ── Constantes ────────────────────────────────────────────────
const AVATAR_BUCKET = 'avatars'
const TIPO_OPTIONS = [
'MEI', 'ME', 'EPP', 'EIRELI', 'SLU', 'LTDA', 'S/A',
'Associação', 'Fundação', 'Cooperativa', 'Outro'
]
const REDES_OPTIONS = [
{ label: 'Instagram', value: 'instagram' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'X / Twitter', value: 'twitter' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'TikTok', value: 'tiktok' },
{ label: 'WhatsApp', value: 'whatsapp' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Pinterest', value: 'pinterest' },
{ label: 'Outro', value: 'outro' },
]
const REDE_ICONS = {
instagram: 'pi-instagram',
facebook: 'pi-facebook',
linkedin: 'pi-linkedin',
twitter: 'pi-twitter',
youtube: 'pi-youtube',
whatsapp: 'pi-whatsapp',
tiktok: 'pi-tiktok',
telegram: 'pi-send',
pinterest: 'pi-heart',
outro: 'pi-link',
}
const ESTADOS = [
'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS',
'MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC',
'SP','SE','TO'
]
// ── Estado ────────────────────────────────────────────────────
const tenantId = ref(null)
const recordId = ref(null)
const saving = ref(false)
const loadingCep = ref(false)
const form = ref({
nome_fantasia: '',
razao_social: '',
tipo_empresa: null,
cnpj: '',
ie: '',
im: '',
cep: '',
logradouro: '',
numero: '',
complemento: '',
bairro: '',
cidade: '',
estado: null,
email: '',
telefone: '',
site: '',
logo_url: '',
redes_sociais: [],
})
// ── Computed preview ──────────────────────────────────────────
const logoDisplay = computed(() => logoPreview.value || form.value.logo_url || null)
const enderecoLinhas = computed(() => {
const f = form.value
const lines = []
if (f.logradouro) {
let l = f.logradouro
if (f.numero) l += `, ${f.numero}`
if (f.complemento) l += `${f.complemento}`
lines.push(l)
}
if (f.bairro) lines.push(f.bairro)
const ce = [f.cidade, f.estado].filter(Boolean).join(' / ')
if (ce) lines.push(ce)
if (f.cep) lines.push(`CEP ${f.cep}`)
return lines
})
const redesValidas = computed(() =>
form.value.redes_sociais.filter(r => r.rede && r.url)
)
const temDados = computed(() =>
!!(form.value.nome_fantasia || form.value.razao_social || logoDisplay.value)
)
// ── Logo upload ───────────────────────────────────────────────
const logoInputRef = ref(null)
const logoPreview = ref(null)
const logoFile = ref(null)
const uploadingLogo = ref(false)
function onLogoClick() { logoInputRef.value?.click() }
function onLogoChange(e) {
const file = e.target.files?.[0]
if (!file) return
if (!['image/jpeg','image/png','image/webp','image/svg+xml'].includes(file.type)) {
toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Use JPG, PNG, WEBP ou SVG.', life: 3000 })
return
}
logoFile.value = file
logoPreview.value = URL.createObjectURL(file)
}
function removeLogo() {
logoFile.value = null
logoPreview.value = null
form.value.logo_url = ''
if (logoInputRef.value) logoInputRef.value.value = ''
}
async function uploadLogo() {
if (!logoFile.value || !tenantId.value) return form.value.logo_url
const ext = logoFile.value.name.split('.').pop().toLowerCase()
const path = `${tenantId.value}/company-logo-${Date.now()}.${ext}`
uploadingLogo.value = true
try {
const { error } = await supabase.storage
.from(AVATAR_BUCKET)
.upload(path, logoFile.value, { upsert: true, contentType: logoFile.value.type })
if (error) throw error
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
return data?.publicUrl || ''
} finally {
uploadingLogo.value = false
}
}
// ── CEP ───────────────────────────────────────────────────────
async function buscarCep() {
const cep = form.value.cep.replace(/\D/g, '')
if (cep.length !== 8) return
loadingCep.value = true
try {
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
const data = await res.json()
if (data.erro) { toast.add({ severity: 'warn', summary: 'CEP não encontrado', life: 3000 }); return }
form.value.logradouro = data.logradouro || ''
form.value.bairro = data.bairro || ''
form.value.cidade = data.localidade || ''
form.value.estado = data.uf || null
} catch {
toast.add({ severity: 'error', summary: 'Erro ao buscar CEP', life: 3000 })
} finally {
loadingCep.value = false
}
}
// ── Redes sociais ─────────────────────────────────────────────
function addRede() { form.value.redes_sociais.push({ rede: null, url: '' }) }
function removeRede(i) { form.value.redes_sociais.splice(i, 1) }
// ── Load / Save ───────────────────────────────────────────────
async function load() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
tenantId.value = user.id
const { data } = await supabase
.from('company_profiles')
.select('*')
.eq('tenant_id', user.id)
.maybeSingle()
if (data) {
recordId.value = data.id
Object.keys(form.value).forEach(k => {
if (data[k] !== undefined && data[k] !== null) form.value[k] = data[k]
})
if (data.logo_url) logoPreview.value = data.logo_url
}
}
async function save() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
toast.add({ severity: 'error', summary: 'Sessão expirada', detail: 'Faça login novamente.', life: 4000 })
return
}
tenantId.value = user.id
saving.value = true
try {
if (logoFile.value) form.value.logo_url = await uploadLogo()
const payload = {
tenant_id: tenantId.value,
nome_fantasia: form.value.nome_fantasia || null,
razao_social: form.value.razao_social || null,
tipo_empresa: form.value.tipo_empresa || null,
cnpj: form.value.cnpj || null,
ie: form.value.ie || null,
im: form.value.im || null,
cep: form.value.cep || null,
logradouro: form.value.logradouro || null,
numero: form.value.numero || null,
complemento: form.value.complemento || null,
bairro: form.value.bairro || null,
cidade: form.value.cidade || null,
estado: form.value.estado || null,
email: form.value.email || null,
telefone: form.value.telefone || null,
site: form.value.site || null,
logo_url: form.value.logo_url || null,
redes_sociais: form.value.redes_sociais.filter(r => r.rede && r.url),
}
if (recordId.value) {
const { error } = await supabase.from('company_profiles').update(payload).eq('id', recordId.value)
if (error) throw error
} else {
const { data, error } = await supabase.from('company_profiles').insert(payload).select('id').single()
if (error) throw error
recordId.value = data.id
}
logoFile.value = null
toast.add({ severity: 'success', summary: 'Dados salvos com sucesso', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
saving.value = false
}
}
// ── Dados de exemplo ──────────────────────────────────────────
function preencherExemplo() {
form.value.nome_fantasia = 'Clínica Mente Sã'
form.value.razao_social = 'Mente Sã Serviços de Psicologia Ltda'
form.value.tipo_empresa = 'LTDA'
form.value.cnpj = '12.345.678/0001-90'
form.value.ie = '123.456.789.110'
form.value.im = '98765-4'
form.value.cep = '13561-260'
form.value.logradouro = 'Rua Episcopal'
form.value.numero = '738'
form.value.complemento = 'Sala 12 — 2º andar'
form.value.bairro = 'Centro'
form.value.cidade = 'São Carlos'
form.value.estado = 'SP'
form.value.email = 'contato@mentesa.com.br'
form.value.telefone = '(16) 99123-4567'
form.value.site = 'https://www.mentesa.com.br'
form.value.redes_sociais = [
{ rede: 'instagram', url: 'https://instagram.com/mentesa' },
{ rede: 'facebook', url: 'https://facebook.com/mentesa' },
{ rede: 'linkedin', url: 'https://linkedin.com/company/mentesa' },
{ rede: 'whatsapp', url: 'https://wa.me/5516991234567' },
]
}
onMounted(load)
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-building" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Minha Empresa</div>
<div class="cfg-subheader__sub">Dados da empresa, logomarca e presença digital</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Exemplo" icon="pi pi-magic-wand" size="small" severity="secondary" outlined class="rounded-full" @click="preencherExemplo" />
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="saving" @click="save" />
</div>
</div>
<!-- Corpo: formulário + preview -->
<div class="flex gap-4 items-start">
<!-- Formulário (60%) -->
<div class="form-col">
<!-- Logomarca -->
<div class="cfg-card">
<div class="cfg-card__head">
<i class="pi pi-image text-xs opacity-50" />
<span>Logomarca</span>
</div>
<div class="flex items-center gap-5 px-4 py-4">
<div class="logo-upload-preview" :class="{ empty: !logoDisplay }" @click="onLogoClick">
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="w-full h-full object-contain p-1" />
<div v-else class="flex flex-col items-center gap-1 opacity-40">
<i class="pi pi-image text-2xl" />
<span class="text-[0.65rem]">Clique para adicionar</span>
</div>
</div>
<input ref="logoInputRef" type="file" accept="image/*" class="hidden" @change="onLogoChange" />
<div class="flex flex-col gap-2">
<Button label="Carregar imagem" icon="pi pi-upload" size="small" outlined @click="onLogoClick" :loading="uploadingLogo" />
<Button v-if="logoDisplay" label="Remover" icon="pi pi-trash" size="small" severity="danger" text @click="removeLogo" />
<p class="text-[0.7rem] text-[var(--text-color-secondary)] m-0">JPG, PNG, WEBP ou SVG.<br>Recomendado: fundo transparente.</p>
</div>
</div>
</div>
<!-- Identificação -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-id-card text-xs opacity-50" />
<span>Identificação</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 py-4">
<div class="flex flex-col gap-1">
<label class="cfg-label">Nome fantasia</label>
<InputText v-model="form.nome_fantasia" placeholder="Nome comercial da empresa" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Razão social</label>
<InputText v-model="form.razao_social" placeholder="Razão social completa" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Tipo de empresa</label>
<Select v-model="form.tipo_empresa" :options="TIPO_OPTIONS" placeholder="Selecione" class="w-full" show-clear />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">CNPJ</label>
<InputText v-model="form.cnpj" placeholder="00.000.000/0001-00" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Inscrição Estadual (IE)</label>
<InputText v-model="form.ie" placeholder="Inscrição Estadual" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Inscrição Municipal (IM)</label>
<InputText v-model="form.im" placeholder="Inscrição Municipal" class="w-full" />
</div>
</div>
</div>
<!-- Endereço -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-map-marker text-xs opacity-50" />
<span>Endereço</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-6 gap-3 px-4 py-4">
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">CEP</label>
<div class="flex gap-2">
<InputText v-model="form.cep" placeholder="00000-000" class="w-full" @blur="buscarCep" @keydown.enter.prevent="buscarCep" />
<Button icon="pi pi-search" severity="secondary" outlined :loading="loadingCep" @click="buscarCep" v-tooltip="'Buscar CEP'" />
</div>
</div>
<div class="flex flex-col gap-1 md:col-span-3">
<label class="cfg-label">Logradouro</label>
<InputText v-model="form.logradouro" placeholder="Rua, Avenida..." class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-1">
<label class="cfg-label">Número</label>
<InputText v-model="form.numero" placeholder="Nº" class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Complemento</label>
<InputText v-model="form.complemento" placeholder="Sala, apto..." class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Bairro</label>
<InputText v-model="form.bairro" placeholder="Bairro" class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Cidade</label>
<InputText v-model="form.cidade" placeholder="Cidade" class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Estado</label>
<Select v-model="form.estado" :options="ESTADOS" placeholder="UF" class="w-full" show-clear />
</div>
</div>
</div>
<!-- Contato -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-globe text-xs opacity-50" />
<span>Contato e Presença Digital</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 px-4 py-4">
<div class="flex flex-col gap-1">
<label class="cfg-label">E-mail da empresa</label>
<InputText v-model="form.email" placeholder="contato@empresa.com.br" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Telefone / WhatsApp</label>
<InputText v-model="form.telefone" placeholder="(00) 00000-0000" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Site</label>
<InputText v-model="form.site" placeholder="https://www.empresa.com.br" class="w-full" />
</div>
</div>
</div>
<!-- Redes Sociais -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-share-alt text-xs opacity-50" />
<span>Redes Sociais</span>
<Button label="Adicionar" icon="pi pi-plus" size="small" severity="secondary" outlined class="ml-auto" @click="addRede" />
</div>
<div class="px-4 py-3 flex flex-col gap-2">
<p v-if="form.redes_sociais.length === 0" class="text-sm text-[var(--text-color-secondary)] italic m-0 py-2">
Nenhuma rede social adicionada.
</p>
<div v-for="(item, i) in form.redes_sociais" :key="i" class="flex gap-2 items-center">
<Select v-model="item.rede" :options="REDES_OPTIONS" option-label="label" option-value="value" placeholder="Rede" class="w-40 shrink-0" />
<InputText v-model="item.url" :placeholder="item.rede ? `URL do ${item.rede}` : 'URL do perfil'" class="flex-1" />
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="removeRede(i)" />
</div>
</div>
</div>
<!-- Salvar -->
<div class="flex justify-end mt-4 pb-2">
<Button label="Salvar dados da empresa" icon="pi pi-check" :loading="saving" @click="save" />
</div>
</div>
<!-- Preview (40%) -->
<div class="preview-col">
<div class="preview-card">
<!-- Placeholder vazio -->
<div v-if="!temDados" class="preview-empty">
<i class="pi pi-building text-3xl opacity-20" />
<p class="text-xs opacity-40 mt-2 text-center">Preencha os dados<br>para ver o preview</p>
</div>
<template v-else>
<!-- Logo -->
<div class="preview-logo-wrap">
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="preview-logo" />
<div v-else class="preview-logo-placeholder">
<i class="pi pi-building text-xl opacity-30" />
</div>
</div>
<!-- Nome + tipo -->
<div class="preview-name-block">
<h2 v-if="form.nome_fantasia" class="preview-name">{{ form.nome_fantasia }}</h2>
<p v-if="form.razao_social" class="preview-razao">{{ form.razao_social }}</p>
<span v-if="form.tipo_empresa" class="preview-tipo">{{ form.tipo_empresa }}</span>
</div>
<!-- Documentos -->
<div v-if="form.cnpj || form.ie || form.im" class="preview-section">
<div class="preview-divider" />
<div class="preview-docs">
<div v-if="form.cnpj" class="preview-doc-row">
<span class="preview-doc-label">CNPJ</span>
<span class="preview-doc-value">{{ form.cnpj }}</span>
</div>
<div v-if="form.ie" class="preview-doc-row">
<span class="preview-doc-label">IE</span>
<span class="preview-doc-value">{{ form.ie }}</span>
</div>
<div v-if="form.im" class="preview-doc-row">
<span class="preview-doc-label">IM</span>
<span class="preview-doc-value">{{ form.im }}</span>
</div>
</div>
</div>
<!-- Endereço -->
<div v-if="enderecoLinhas.length" class="preview-section">
<div class="preview-divider" />
<div class="preview-info-row">
<i class="pi pi-map-marker preview-info-icon" />
<div class="flex flex-col gap-0.5">
<span v-for="(linha, i) in enderecoLinhas" :key="i" class="preview-info-text">{{ linha }}</span>
</div>
</div>
</div>
<!-- Contato -->
<div v-if="form.email || form.telefone || form.site" class="preview-section">
<div class="preview-divider" />
<div class="flex flex-col gap-1.5">
<div v-if="form.email" class="preview-info-row">
<i class="pi pi-envelope preview-info-icon" />
<span class="preview-info-text">{{ form.email }}</span>
</div>
<div v-if="form.telefone" class="preview-info-row">
<i class="pi pi-phone preview-info-icon" />
<span class="preview-info-text">{{ form.telefone }}</span>
</div>
<div v-if="form.site" class="preview-info-row">
<i class="pi pi-globe preview-info-icon" />
<span class="preview-info-text truncate">{{ form.site }}</span>
</div>
</div>
</div>
<!-- Redes sociais -->
<div v-if="redesValidas.length" class="preview-section">
<div class="preview-divider" />
<div class="flex flex-wrap gap-2">
<a
v-for="(r, i) in redesValidas"
:key="i"
:href="r.url"
target="_blank"
class="preview-rede"
:title="r.rede"
>
<i :class="`pi ${REDE_ICONS[r.rede] || 'pi-link'}`" />
<span>{{ REDES_OPTIONS.find(o => o.value === r.rede)?.label ?? r.rede }}</span>
</a>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Subheader ─────────────────────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Layout colunas ────────────────────────────────────────── */
.preview-col {
width: 40%;
flex-shrink: 0;
position: sticky;
top: calc(var(--layout-sticky-top, 56px) + 58px);
align-self: flex-start;
}
.form-col {
flex: 1;
min-width: 0;
}
/* ── Preview card ──────────────────────────────────────────── */
.preview-card {
border: 1px solid var(--surface-border);
border-radius: 12px;
background: var(--surface-card);
overflow: hidden;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0;
}
.preview-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 200px;
color: var(--text-color-secondary);
}
.preview-logo-wrap {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.preview-logo {
max-width: 120px;
max-height: 72px;
object-fit: contain;
}
.preview-logo-placeholder {
width: 72px; height: 72px;
border-radius: 8px;
background: var(--surface-ground);
border: 2px dashed var(--surface-border);
display: flex; align-items: center; justify-content: center;
color: var(--text-color-secondary);
}
.preview-name-block {
text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.preview-name {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text-color);
margin: 0;
}
.preview-razao {
font-size: 0.75rem;
color: var(--text-color-secondary);
margin: 0;
}
.preview-tipo {
display: inline-block;
margin-top: 4px;
padding: 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 25%, transparent);
color: var(--primary-color,#6366f1);
font-size: 0.7rem;
font-weight: 700;
}
.preview-section { margin-top: 0.75rem; }
.preview-divider { height: 1px; background: var(--surface-border); margin-bottom: 0.75rem; }
.preview-docs { display: flex; flex-direction: column; gap: 4px; }
.preview-doc-row { display: flex; gap: 8px; align-items: baseline; }
.preview-doc-label {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-color-secondary); opacity: 0.6;
width: 28px; flex-shrink: 0;
}
.preview-doc-value { font-size: 0.8rem; color: var(--text-color); font-family: monospace; }
.preview-info-row { display: flex; align-items: flex-start; gap: 8px; }
.preview-info-icon { font-size: 0.75rem; color: var(--primary-color,#6366f1); opacity: 0.7; margin-top: 2px; flex-shrink: 0; }
.preview-info-text { font-size: 0.8rem; color: var(--text-color); line-height: 1.4; }
.preview-rede {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
color: var(--text-color);
font-size: 0.72rem;
font-weight: 600;
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
}
.preview-rede:hover {
border-color: color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
background: color-mix(in srgb, var(--primary-color,#6366f1) 6%, var(--surface-card));
}
.preview-rede i { font-size: 0.75rem; }
/* ── Form cards ────────────────────────────────────────────── */
.cfg-card {
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-card__head {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.82rem;
font-weight: 700;
}
.cfg-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.logo-upload-preview {
width: 120px; height: 80px;
border: 2px dashed var(--surface-border);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0;
background: var(--surface-ground);
transition: border-color 0.15s;
overflow: hidden;
}
.logo-upload-preview:hover { border-color: var(--primary-color,#6366f1); }
.logo-upload-preview.empty { color: var(--text-color-secondary); }
</style>

View File

@@ -0,0 +1,74 @@
/**
* Constantes compartilhadas do sistema de Email Templates.
* Importável em Vue components, workers, Edge Functions, etc.
*/
export const TEMPLATE_DOMAINS = {
SESSION: 'session',
INTAKE: 'intake',
BILLING: 'billing',
SYSTEM: 'system',
}
export const TEMPLATE_CHANNELS = {
EMAIL: 'email',
WHATSAPP: 'whatsapp',
SMS: 'sms',
}
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
INTAKE_RECEIVED: 'intake.received.email',
INTAKE_APPROVED: 'intake.approved.email',
INTAKE_REJECTED: 'intake.rejected.email',
// Agendador online
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',
}
/**
* Dados mock realistas para preview de templates na UI.
* Espelham as entidades reais: patients, agenda_eventos, patient_intake_requests.
*/
export const MOCK_DATA = {
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,
rejection_reason: null,
},
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',
rejection_reason: null,
},
system: {
patient_name: 'Mariana Souza',
clinic_name: 'Clínica Harmonia',
portal_link: 'https://app.exemplo.com.br/portal',
reset_link: 'https://app.exemplo.com.br/reset-password?token=mock',
},
}

View File

@@ -0,0 +1,335 @@
/**
* Email Template Service — Multi-tenant
*
* Hierarquia de resolução:
* 1. Override do terapeuta (owner_id + tenant_id + key)
* 2. Override do tenant (tenant_id + key, sem owner_id)
* 3. Template global (key, is_active = true)
*
* Merging inteligente: campos null no override herdam do global,
* permitindo sobrescrever só o subject sem reescrever o body.
*/
import { supabase } from '@/lib/supabase/client'
import { MOCK_DATA } from './emailTemplateConstants'
// ─────────────────────────────────────────────
// QUERIES SUPABASE
// ─────────────────────────────────────────────
async function _fetchGlobalTemplate(key) {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.eq('key', key)
.eq('is_active', true)
.single()
if (error && error.code !== 'PGRST116') {
console.error('[EmailTemplates] Erro ao buscar global:', error)
}
return data ?? null
}
async function _fetchAllGlobalTemplates() {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.eq('is_active', true)
.order('domain')
if (error) console.error('[EmailTemplates] Erro ao listar globais:', error)
return data ?? []
}
/**
* Busca override de tenant/owner para uma key.
* Se ownerId fornecido: prioriza override do owner; fallback para tenant geral.
* Se não: busca só o override do tenant (owner_id IS NULL).
*/
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)
if (ownerId) {
// Retorna até 2 linhas: a do owner e a do tenant geral
// Ordena owner_id DESC NULLS LAST → owner primeiro
query = query
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('owner_id', { nullsFirst: false })
.limit(1)
} else {
query = query.is('owner_id', null).limit(1)
}
const { data, error } = await query.maybeSingle()
if (error) console.error('[EmailTemplates] Erro ao buscar tenant override:', error)
return data ?? null
}
// ─────────────────────────────────────────────
// MERGE & FALLBACK
// ─────────────────────────────────────────────
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,
_needs_sync: override.synced_version !== null && override.synced_version < global.version,
}
}
function _fallbackTemplate(key) {
return {
id: null,
key,
domain: 'system',
channel: 'email',
subject: `[${key}] Notificação do sistema`,
body_html: '<p>Você recebeu uma notificação do sistema.</p>',
body_text: 'Você recebeu uma notificação do sistema.',
version: 1,
is_active: true,
variables: {},
_source: 'fallback',
}
}
// ─────────────────────────────────────────────
// RENDERER
// ─────────────────────────────────────────────
/**
* Resolve uma variável com suporte a notação de ponto (patient.name).
*/
function _resolveVariable(key, variables) {
if (!key.includes('.')) return variables[key]
return key.split('.').reduce((obj, part) => obj?.[part], variables)
}
/**
* Renderiza um template substituindo {{variavel}} e blocos {{#if}}.
*
* @param {string} template
* @param {Object} variables
* @returns {string}
*/
export function renderTemplate(template, variables = {}) {
if (!template) return ''
let result = template
// Blocos condicionais {{#if var}}...{{/if}}
result = result.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key, content) => _resolveVariable(key, variables) ? content : ''
)
// Substituições simples {{variavel}}
result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const value = _resolveVariable(key, variables)
return value !== undefined && value !== null ? String(value) : ''
})
return result
}
function _stripHtml(html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// ─────────────────────────────────────────────
// LAYOUT — Header / Footer gerado por config
// ─────────────────────────────────────────────
/**
* Gera o HTML inline de header ou footer a partir da config do tenant.
* Usa <table> para máxima compatibilidade com clientes de e-mail.
*
* @param {Object} config - { enabled, show_logo, logo_position, text, text_align }
* @param {string|null} logoUrl
* @param {boolean} isHeader - true = header, false = footer
* @returns {string}
*/
export function generateLayoutSection(config, logoUrl, isHeader = true) {
if (!config?.enabled) return ''
const borderStyle = isHeader
? 'border-bottom:2px solid #e5e7eb;padding-bottom:16px;margin-bottom:20px;'
: 'border-top:1px solid #e5e7eb;padding-top:16px;margin-top:20px;'
const layout = config.layout || null
const text = config.content?.trim() || ''
if (!layout && !text) return ''
const logoImg = logoUrl
? `<img src="${logoUrl}" style="max-width:90px;max-height:56px;object-fit:contain;display:block;">`
: ''
let inner = ''
if (layout === 'logo-left') {
inner = `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td style="width:106px;vertical-align:middle;padding-right:16px;">${logoImg}</td>
<td style="vertical-align:middle;">${text}</td>
</tr>
</table>`
} else if (layout === 'logo-right') {
inner = `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td style="vertical-align:middle;">${text}</td>
<td style="width:106px;vertical-align:middle;padding-left:16px;text-align:right;">${logoImg}</td>
</tr>
</table>`
} else if (layout === 'logo-center') {
const centeredImg = logoUrl
? `<img src="${logoUrl}" style="max-width:90px;max-height:56px;object-fit:contain;display:inline-block;">`
: ''
inner = `<div style="text-align:center;">${centeredImg}<div style="margin-top:8px;">${text}</div></div>`
} else if (layout === 'text-left') {
inner = `<div style="text-align:left;">${text}</div>`
} else if (layout === 'text-center') {
inner = `<div style="text-align:center;">${text}</div>`
} else if (layout === 'text-right') {
inner = `<div style="text-align:right;">${text}</div>`
} else {
inner = text
}
if (!inner.trim()) return ''
return `<div style="${borderStyle}">${inner}</div>`
}
/**
* Renderiza subject e ambos os bodies de um template resolvido.
*
* @param {Object} resolvedTemplate
* @param {Object} variables
* @param {Object} [options] - { headerConfig, footerConfig, logoUrl }
* @returns {{ subject: string, body_html: string, body_text: string }}
*/
export function renderEmail(resolvedTemplate, variables = {}, options = {}) {
const { headerConfig, footerConfig, logoUrl } = options
const headerHtml = generateLayoutSection(headerConfig, logoUrl, true)
const footerHtml = generateLayoutSection(footerConfig, logoUrl, false)
const body_html = renderTemplate(resolvedTemplate.body_html, variables)
const full_html = headerHtml + body_html + footerHtml
return {
subject: renderTemplate(resolvedTemplate.subject, variables),
body_html: full_html,
body_text: resolvedTemplate.body_text
? renderTemplate(resolvedTemplate.body_text, variables)
: _stripHtml(full_html),
}
}
// ─────────────────────────────────────────────
// API PÚBLICA
// ─────────────────────────────────────────────
/**
* Resolve o template correto para um contexto de tenant/owner.
*
* @param {string} templateKey - TEMPLATE_KEYS.*
* @param {{ tenantId?: string, ownerId?: string }} [context]
* @returns {Promise<Object>}
*/
export async function getEmailTemplate(templateKey, context = {}) {
const { tenantId = null, ownerId = null } = context
const global = await _fetchGlobalTemplate(templateKey)
if (!global) {
console.warn(`[EmailTemplates] Template não encontrado: ${templateKey}`)
return _fallbackTemplate(templateKey)
}
if (!tenantId) return { ...global, _source: 'global' }
// Busca override (owner tem prioridade sobre tenant geral)
const tenantOverride = await _fetchTenantTemplate(tenantId, ownerId, templateKey)
if (!tenantOverride) return { ...global, _source: 'global' }
return _mergeTemplates(global, tenantOverride)
}
/**
* Retorna dados mock para um template key (baseado no domínio).
*/
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
}
/**
* Gera preview renderizado com dados mockados.
* Uso principal: tela de edição de templates na UI do terapeuta.
*
* @param {string} templateKey
* @param {Object} [mockData] - substitui campos do mock padrão
* @param {Object} [context] - { tenantId, ownerId }
* @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),
},
}
}
/**
* Lista todos os templates globais com status de override por contexto.
* Útil para a tela de gerenciamento de templates na UI do terapeuta.
*
* @param {Object} [context] - { tenantId, ownerId }
* @returns {Promise<Array>}
*/
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,
}
})
)
}

View File

@@ -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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Este é um lembrete da sua sessão agendada para <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong>.</p>
<p>Modalidade: <strong>{{session_modality}}</strong></p>
{{#if session_link}}
<p><a href="{{session_link}}">Clique aqui para entrar na sessão online</a></p>
{{/if}}
<p>Em caso de necessidade de cancelamento, entre em contato com antecedência.</p>
<p>Até logo,<br><strong>{{therapist_name}}</strong></p>
`,
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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua sessão foi confirmada com sucesso.</p>
<ul>
<li><strong>Data:</strong> {{session_date}}</li>
<li><strong>Horário:</strong> {{session_time}}</li>
<li><strong>Modalidade:</strong> {{session_modality}}</li>
{{#if session_address}}<li><strong>Local:</strong> {{session_address}}</li>{{/if}}
</ul>
<p>Até lá,<br><strong>{{therapist_name}}</strong></p>
`,
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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Informamos que sua sessão do dia <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong> foi cancelada.</p>
{{#if cancellation_reason}}<p>Motivo: {{cancellation_reason}}</p>{{/if}}
<p>Entre em contato para reagendar.</p>
<p><strong>{{therapist_name}}</strong></p>
`,
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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Recebemos seu cadastro com sucesso. Nossa equipe entrará em contato em breve para dar continuidade ao processo.</p>
<p>Obrigado pela confiança,<br><strong>{{clinic_name}}</strong></p>
`,
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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Seu cadastro foi aprovado. Você já pode acessar o portal e agendar sua primeira sessão.</p>
<p><a href="{{portal_link}}">Acessar portal →</a></p>
<p>Qualquer dúvida, estamos à disposição.<br><strong>{{therapist_name}}</strong></p>
`,
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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua solicitação de agendamento foi aceita.</p>
<ul>
<li><strong>Data:</strong> {{session_date}}</li>
<li><strong>Horário:</strong> {{session_time}}</li>
<li><strong>Tipo:</strong> {{session_type}}</li>
<li><strong>Modalidade:</strong> {{session_modality}}</li>
</ul>
<p>Até logo,<br><strong>{{therapist_name}}</strong></p>
`,
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: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Seja bem-vindo(a)! Sua conta foi criada com sucesso.</p>
<p><a href="{{portal_link}}">Acessar minha área →</a></p>
`,
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(/<br\s*\/?>/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: `<p>Você recebeu uma notificação do sistema.</p>`,
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<Array>}
*/
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: '<p>Olá {{patient_name}}, sou a Dra. Beatriz. Até amanhã! 🌿</p>',
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)

View File

@@ -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' }
]
}
]

View File

@@ -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')
}
]
}

View File

@@ -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 }
}
]
}

View File

@@ -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'));

225
src/stores/noticeStore.js Normal file
View File

@@ -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()
}
}
})

View File

@@ -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()}`
}

View File

@@ -0,0 +1,353 @@
<!-- src/views/pages/saas/SaasEmailTemplatesPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Editor from 'primevue/editor'
import { supabase } from '@/lib/supabase/client'
import { renderEmail } from '@/lib/email/emailTemplateService'
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
const toast = useToast()
// ── Perfil (logo no preview) ───────────────────────────────────
const profileLogoUrl = ref(null)
async function loadProfile() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', user.id)
.maybeSingle()
profileLogoUrl.value = data?.avatar_url || null
}
// ── Lista ──────────────────────────────────────────────────────
const templates = ref([])
const loading = ref(false)
const filterDomain = ref(null)
async function load() {
loading.value = true
try {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.order('domain')
.order('key')
if (error) throw error
templates.value = data
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
}
const filtered = computed(() => {
if (!filterDomain.value) return templates.value
return templates.value.filter(t => t.domain === filterDomain.value)
})
const DOMAIN_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro',value: TEMPLATE_DOMAINS.BILLING },
]
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
// ── Dialog edição ──────────────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null })
const form = ref({})
const editorRef = ref(null)
function openEdit(t) {
form.value = {
key: t.key,
subject: t.subject,
body_html: t.body_html,
body_text: t.body_text ?? '',
version: t.version,
is_active: t.is_active,
variables: t.variables || {},
}
dlg.value = { open: true, saving: false, id: t.id }
}
function closeDlg() { dlg.value.open = false }
const formVariables = computed(() => {
const keys = Object.keys(form.value.variables || {})
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
return keys
})
// Insere {{varName}} na posição do cursor no Editor (Quill)
function insertVar(varName) {
const snippet = `{{${varName}}}`
const quill = editorRef.value?.quill
if (!quill) {
form.value.body_html = (form.value.body_html || '') + snippet
return
}
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength() - 1
quill.insertText(index, snippet, 'user')
quill.setSelection(index + snippet.length, 0)
}
async function save() {
if (!form.value.subject?.trim() || !form.value.body_html?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject e body são obrigatórios', life: 3000 })
return
}
dlg.value.saving = true
try {
const { error } = await supabase
.from('email_templates_global')
.update({
subject: form.value.subject,
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: form.value.version,
is_active: form.value.is_active,
})
.eq('id', dlg.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 })
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
}
async function toggleActive(t) {
try {
const { error } = await supabase
.from('email_templates_global')
.update({ is_active: !t.is_active })
.eq('id', t.id)
if (error) throw error
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
// ── Dialog preview ─────────────────────────────────────────────
const preview = ref({ open: false, subject: '', body_html: '', key: '' })
function openPreview(t) {
const mock = {
..._mockForDomain(t.domain),
clinic_logo_url: profileLogoUrl.value || null,
}
const rendered = renderEmail(t, mock)
preview.value = { open: true, ...rendered, key: t.key }
}
function _mockForDomain(domain) {
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
return { ...MOCK_DATA.system }
}
onMounted(() => {
load()
loadProfile()
})
</script>
<template>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">
Templates base do sistema. Tenants podem criar overrides sem alterar estes.
</p>
</div>
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
</div>
<!-- Filtro -->
<div class="flex gap-2 mb-4 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="t in filtered"
:key="t.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
:class="{ 'opacity-50': !t.is_active }"
>
<Tag
:value="DOMAIN_LABEL[t.domain] ?? t.domain"
:severity="DOMAIN_SEVERITY[t.domain] ?? 'secondary'"
class="text-[0.7rem] shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-[var(--text-color-secondary)] mb-0.5">{{ t.key }}</div>
<div class="text-sm truncate font-medium">{{ t.subject }}</div>
</div>
<div class="hidden md:flex items-center gap-3 text-xs text-[var(--text-color-secondary)] shrink-0">
<span>v{{ t.version }}</span>
<Tag :value="t.channel" severity="secondary" class="text-[0.65rem]" />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(t)" />
<Button icon="pi pi-pencil" text rounded size="small" title="Editar" @click="openEdit(t)" />
<Button
:icon="t.is_active ? 'pi pi-eye-slash' : 'pi pi-eye'"
text rounded size="small"
:severity="t.is_active ? 'secondary' : 'success'"
:title="t.is_active ? 'Desativar' : 'Ativar'"
@click="toggleActive(t)"
/>
</div>
</div>
<div v-if="!filtered.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-envelope text-4xl opacity-30 block mb-3" />
Nenhum template neste domínio.
</div>
</div>
<!-- Dialog Edição -->
<Dialog
v-model:visible="dlg.open"
:header="`Editar — ${form.key}`"
modal
:style="{ width: '860px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Subject -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Subject *</label>
<InputText v-model="form.subject" class="w-full" />
</div>
<!-- Body HTML Editor Quill -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Body HTML *</label>
<Editor
ref="editorRef"
v-model="form.body_html"
editor-style="min-height: 260px; font-size: 0.85rem;"
/>
<!-- Botões de variáveis -->
<div class="flex flex-col gap-1.5 mt-1">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables"
:key="v"
:label="`{{${v}}}`"
size="small"
severity="secondary"
outlined
class="font-mono !text-[0.68rem] !py-1 !px-2"
@click="insertVar(v)"
/>
</div>
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0">
Suporta <code>&#123;&#123;variavel&#125;&#125;</code> e
<code>&#123;&#123;#if variavel&#125;&#125;...&#123;&#123;/if&#125;&#125;</code>
</p>
</div>
<!-- Body Text -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">
Body Text
<span class="font-normal opacity-60">(opcional gerado do HTML se vazio)</span>
</label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
</div>
<!-- Versão (esquerda) + Ativo (direita) -->
<div class="flex items-end justify-between">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Versão</label>
<InputNumber v-model="form.version" :min="1" style="width:110px" />
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-global" />
<label for="sw-active-global" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button label="Salvar" icon="pi pi-check" :loading="dlg.saving" @click="save" />
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog
v-model:visible="preview.open"
:header="`Preview — ${preview.key}`"
modal
:style="{ width: '700px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800">
<div v-if="profileLogoUrl" class="mb-4 text-center">
<img :src="profileLogoUrl" alt="Logo" class="h-10 object-contain inline-block" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="text-sm leading-relaxed" v-html="preview.body_html" />
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
Preview com dados de exemplo.
<span v-if="profileLogoUrl"> Logo do seu perfil.</span>
<span v-else> Sem logo configure em <b>Meu Perfil Avatar</b>.</span>
</p>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
</div>
</template>

View File

@@ -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: <b>${form.value.nome.trim()}</b> — ${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: <b>${feriado.nome}</b> — ${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) {
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-6">
<!-- Feriados Nacionais -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="flex items-center gap-2 px-5 py-3 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-flag text-blue-500" />
<span class="font-bold text-sm">Feriados Nacionais {{ ano }}</span>
<span class="text-[0.72rem] text-[var(--text-color-secondary)] ml-1">(gerados automaticamente)</span>
</div>
<div class="flex flex-col">
<div
v-for="f in nacionais"
:key="f.data"
class="flex flex-col border-b border-[var(--surface-border)] last:border-b-0"
:class="isPassado(f.data) ? 'opacity-40' : ''"
>
<!-- Linha principal -->
<div
class="flex items-center gap-3 px-5 py-2.5 flex-wrap"
:class="!isPassado(f.data) ? 'hover:bg-[var(--surface-hover)]' : ''"
>
<!-- Data -->
<span class="font-mono text-xs font-semibold bg-[var(--surface-ground)] border border-[var(--surface-border)] rounded px-2 py-0.5 shrink-0">
{{ fmtDate(f.data) }}
</span>
<!-- Nome -->
<span class="flex-1 min-w-[160px] text-sm font-medium" :class="{ 'line-through opacity-50': publicadosDatas.has(f.data) }">
{{ f.nome }}
</span>
<!-- Tags -->
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-[0.68rem]" />
<Tag v-if="isPassado(f.data)" value="Passado" severity="secondary" class="text-[0.68rem] opacity-60" />
<Tag v-if="publicadosDatas.has(f.data)" value="Publicado" severity="success" class="text-[0.68rem]" />
<!-- Ícone de estado -->
<template v-if="!isPassado(f.data)">
<!-- Publicado clicável para despublicar -->
<button
v-if="estadoNacional(f.data) === 'published'"
v-tooltip.top="'Publicado clique para despublicar'"
class="snf-lock snf-lock--published"
@click="abrirDlgUnpublish(f)"
>
<i class="pi pi-lock text-xs" />
</button>
<!-- Salvando / despublicando -->
<span v-else-if="salvandoNacional === f.data || despublicando" class="snf-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Confirmação inline aberta botão fechar -->
<button
v-else-if="confirmandoNacional === f.data"
v-tooltip.top="'Fechar'"
class="snf-lock snf-lock--active"
@click="confirmandoNacional = null"
>
<i class="pi pi-times text-xs" />
</button>
<!-- Declinado círculo X, clicável para reverter -->
<button
v-else-if="estadoNacional(f.data) === 'declined'"
v-tooltip.top="'Marcado como não publicar clique para reverter'"
class="snf-lock snf-lock--declined"
@click="reverterDeclinado(f.data)"
>
<i class="pi pi-times-circle text-xs" />
</button>
<!-- Idle cadeado aberto -->
<button
v-else
v-tooltip.top="'Definir publicação deste feriado'"
class="snf-lock snf-lock--idle"
@click="pedirConfirmacaoNacional(f.data)"
>
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline Publicar ou Não publicar -->
<Transition name="snf-expand">
<div v-if="confirmandoNacional === f.data" class="snf-confirm mx-5 mb-2">
<i class="pi pi-question-circle snf-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">O que fazer com <b>{{ f.nome }}</b>?</p>
<p class="text-xs opacity-70 leading-snug">
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.
</p>
</div>
<div class="flex gap-1.5 shrink-0 flex-wrap justify-end">
<Button
label="Não publicar"
size="small"
severity="secondary"
outlined
icon="pi pi-times-circle"
class="rounded-full h-7 text-xs px-3"
@click="declinarNacional(f)"
/>
<Button
label="Sim, publicar"
size="small"
severity="warn"
icon="pi pi-megaphone"
class="rounded-full h-7 text-xs px-3"
@click="abrirDlgPublicar(f)"
/>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Stats municipais -->
<div>
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-star text-amber-500" />
<span class="font-bold text-sm">Feriados Municipais</span>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3">
@@ -303,7 +654,90 @@ async function excluir (id) {
</div>
</template>
</div>
</div><!-- /municipais -->
</div><!-- /content -->
<!-- Dialog despublicação -->
<Dialog
v-model:visible="dlgUnpublish"
modal
:draggable="false"
:style="{ width: '420px' }"
header="Despublicar feriado"
>
<div v-if="feriadoParaDespublicar" class="flex flex-col gap-4 pt-1">
<div class="flex items-center gap-3 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
<i class="pi pi-exclamation-triangle text-red-500 text-lg shrink-0" />
<div>
<p class="font-semibold text-sm m-0">{{ feriadoParaDespublicar.nome }}</p>
<p class="text-xs text-[var(--text-color-secondary)] m-0">{{ fmtDate(feriadoParaDespublicar.data) }}</p>
</div>
</div>
<ul class="text-sm flex flex-col gap-1.5 m-0 pl-0 list-none">
<li class="flex items-start gap-2">
<i class="pi pi-times text-red-500 mt-0.5 shrink-0" />
O feriado será removido da agenda de <b>todos os tenants</b>.
</li>
<li class="flex items-start gap-2">
<i class="pi pi-times text-red-500 mt-0.5 shrink-0" />
O aviso global associado a este feriado será <b>excluído automaticamente</b>.
</li>
</ul>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgUnpublish = false" />
<Button
label="Despublicar"
icon="pi pi-trash"
severity="danger"
:loading="despublicando"
@click="confirmarDespublicacao"
/>
</template>
</Dialog>
<!-- Dialog confirmação final publicar nacional -->
<Dialog
v-model:visible="dlgPublicar"
modal
:draggable="false"
:style="{ width: '440px' }"
header="Confirmar publicação"
>
<div v-if="feriadoParaPublicar" class="flex flex-col gap-4 pt-1">
<div class="flex items-center gap-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-4 py-3">
<i class="pi pi-flag text-amber-500 text-lg shrink-0" />
<div>
<p class="font-semibold text-sm m-0">{{ feriadoParaPublicar.nome }}</p>
<p class="text-xs text-[var(--text-color-secondary)] m-0">{{ fmtDate(feriadoParaPublicar.data) }}</p>
</div>
</div>
<ul class="text-sm flex flex-col gap-1.5 m-0 pl-0 list-none">
<li class="flex items-start gap-2">
<i class="pi pi-users text-blue-500 mt-0.5 shrink-0" />
O feriado ficará visível na agenda de <b>todos os tenants</b>.
</li>
<li class="flex items-start gap-2">
<i class="pi pi-megaphone text-blue-500 mt-0.5 shrink-0" />
Um <b>aviso global</b> será criado automaticamente (você pode editar depois em Avisos Globais).
</li>
<li class="flex items-start gap-2">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
<span class="text-[var(--text-color-secondary)]">Sessões <b>não</b> serão bloqueadas automaticamente cada usuário decide bloquear individualmente.</span>
</li>
</ul>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgPublicar = false" />
<Button
label="Confirmar publicação"
icon="pi pi-check"
severity="warn"
:loading="!!salvandoNacional"
@click="confirmarPublicacao"
/>
</template>
</Dialog>
<!-- Dialog cadastro -->
<Dialog
@@ -380,3 +814,87 @@ async function excluir (id) {
</template>
</Dialog>
</template>
<style scoped>
/* ── Cadeado por feriado nacional ────────────────────────── */
.snf-lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
}
.snf-lock--idle {
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
}
.snf-lock--idle:hover {
color: var(--amber-600, #d97706);
border-color: var(--amber-400, #fbbf24);
background: color-mix(in srgb, var(--amber-400, #fbbf24) 12%, transparent);
}
.snf-lock--active {
color: var(--amber-600, #d97706);
border: 1.5px solid var(--amber-400, #fbbf24);
background: color-mix(in srgb, var(--amber-400, #fbbf24) 14%, transparent);
cursor: pointer;
}
.snf-lock--done {
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
}
.snf-lock--published {
color: var(--green-600, #16a34a);
border: 1.5px solid color-mix(in srgb, var(--green-500, #22c55e) 50%, transparent);
background: color-mix(in srgb, var(--green-500, #22c55e) 10%, transparent);
cursor: pointer;
}
.snf-lock--published:hover {
color: var(--red-500, #ef4444);
border-color: color-mix(in srgb, var(--red-400, #f87171) 50%, transparent);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
}
.snf-lock--declined {
color: var(--red-500, #ef4444);
border: 1.5px solid color-mix(in srgb, var(--red-400, #f87171) 50%, transparent);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
cursor: pointer;
}
.snf-lock--declined:hover {
background: color-mix(in srgb, var(--red-400, #f87171) 20%, transparent);
}
/* ── Confirmação inline ──────────────────────────────────── */
.snf-confirm {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--amber-400, #fbbf24) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--amber-400, #fbbf24) 30%, transparent);
}
.snf-confirm__icon {
color: var(--amber-500, #f59e0b);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
}
/* ── Transição expand ────────────────────────────────────── */
.snf-expand-enter-active,
.snf-expand-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.snf-expand-enter-from,
.snf-expand-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@@ -0,0 +1,685 @@
<!-- src/views/pages/saas/SaasGlobalNoticesPage.vue -->
<!-- Painel de gerenciamento de Avisos Globais (SaaS Admin) -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useRouter } from 'vue-router'
import {
fetchAllNotices,
createNotice,
updateNotice,
deleteNotice,
toggleNoticeActive
} from '@/features/notices/noticeService'
const toast = useToast()
const confirm = useConfirm()
const router = useRouter()
// ── Ajuda ─────────────────────────────────────────────────────
const showHelp = ref(false)
// ── Lista ─────────────────────────────────────────────────────
const notices = ref([])
const loading = ref(false)
async function load () {
loading.value = true
try {
notices.value = await fetchAllNotices()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
}
// ── Dialog ────────────────────────────────────────────────────
const EMPTY = () => ({
title: '',
message: '',
variant: 'info',
roles: [],
contexts: [],
starts_at: null,
ends_at: null,
is_active: true,
priority: 0,
dismissible: true,
persist_dismiss: true,
dismiss_scope: 'device',
show_once: false,
max_views: null,
cooldown_minutes: null,
version: 1,
action_type: 'none',
action_label: '',
action_url: '',
action_route: '',
link_target: '_blank',
content_align: 'left'
})
const dlg = ref({ open: false, saving: false, mode: 'create', id: null })
const form = ref(EMPTY())
function openCreate () {
form.value = EMPTY()
dlg.value = { open: true, saving: false, mode: 'create', id: null }
}
function openEdit (notice) {
form.value = {
...EMPTY(),
...notice,
starts_at: notice.starts_at ? notice.starts_at.slice(0, 16) : null,
ends_at: notice.ends_at ? notice.ends_at.slice(0, 16) : null
}
dlg.value = { open: true, saving: false, mode: 'edit', id: notice.id }
}
function closeDlg () {
dlg.value.open = false
}
async function save () {
if (!form.value.message?.trim()) {
toast.add({ severity: 'warn', summary: 'Mensagem obrigatória', life: 3000 })
return
}
dlg.value.saving = true
try {
const payload = {
...form.value,
starts_at: form.value.starts_at || null,
ends_at: form.value.ends_at || null,
max_views: form.value.max_views || null,
cooldown_minutes: form.value.cooldown_minutes || null,
}
if (dlg.value.mode === 'create') {
await createNotice(payload)
toast.add({ severity: 'success', summary: 'Aviso criado', life: 3000 })
} else {
await updateNotice(dlg.value.id, payload)
toast.add({ severity: 'success', summary: 'Aviso salvo', life: 3000 })
}
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
}
// ── Toggle ativo ──────────────────────────────────────────────
async function toggle (notice) {
try {
await toggleNoticeActive(notice.id, !notice.is_active)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
// ── Excluir ───────────────────────────────────────────────────
function confirmDelete (notice) {
confirm.require({
message: `Excluir o aviso "${notice.title || notice.message.slice(0, 40)}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await deleteNotice(notice.id)
toast.add({ severity: 'success', summary: 'Aviso excluído', life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
})
}
// ── Helpers de exibição ───────────────────────────────────────
const VARIANT_LABELS = { info: 'Info', success: 'Sucesso', warning: 'Atenção', error: 'Erro' }
const VARIANT_SEVERITY = { info: 'info', success: 'success', warning: 'warn', error: 'danger' }
const ROLE_OPTIONS = [
{ label: 'Clinic Admin', value: 'clinic_admin' },
{ label: 'Therapist', value: 'therapist' },
{ label: 'Supervisor', value: 'supervisor' },
{ label: 'Editor', value: 'editor' },
{ label: 'Patient', value: 'patient' },
{ label: 'SaaS Admin', value: 'saas_admin' },
]
const CONTEXT_OPTIONS = [
{ label: 'Clínica (/admin)', value: 'clinic' },
{ label: 'Terapeuta (/therapist)', value: 'therapist' },
{ label: 'Supervisor (/supervisor)', value: 'supervisor' },
{ label: 'Editor (/editor)', value: 'editor' },
{ label: 'Portal (/portal)', value: 'portal' },
{ label: 'SaaS (/saas)', value: 'saas' },
{ label: 'Público', value: 'public' },
]
const VARIANT_OPTIONS = [
{ label: 'Info', value: 'info' },
{ label: 'Sucesso', value: 'success' },
{ label: 'Atenção', value: 'warning' },
{ label: 'Erro', value: 'error' },
]
const SCOPE_OPTIONS = [
{ label: 'Dispositivo (localStorage)', value: 'device' },
{ label: 'Sessão (sessionStorage)', value: 'session' },
{ label: 'Usuário (banco)', value: 'user' },
]
const ALIGN_OPTIONS = [
{ label: 'Esquerda', value: 'left', icon: 'pi pi-align-left' },
{ label: 'Centro', value: 'center', icon: 'pi pi-align-center' },
{ label: 'Direita', value: 'right', icon: 'pi pi-align-right' },
{ label: 'Justificado',value: 'justify', icon: 'pi pi-align-justify' },
]
const ACTION_OPTIONS = [
{ label: 'Nenhuma', value: 'none' },
{ label: 'Interna', value: 'internal' },
{ label: 'Externa', value: 'external' },
]
const TARGET_OPTIONS = [
{ label: '_blank (nova aba)', value: '_blank' },
{ label: '_self (mesma aba)', value: '_self' },
{ label: '_parent', value: '_parent' },
{ label: '_top', value: '_top' },
]
// Rotas internas — exclui rotas sem path útil e agrupa por path
const routeOptions = computed(() => {
return router.getRoutes()
.filter(r => r.path && r.path !== '/' && !r.path.includes(':') && r.name)
.map(r => ({ label: `${r.path} (${String(r.name)})`, value: r.path }))
.sort((a, b) => a.value.localeCompare(b.value))
})
const showCta = computed(() => form.value.action_type !== 'none')
// ── Stats formatadas ──────────────────────────────────────────
function fmtDate (iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
}
onMounted(load)
</script>
<template>
<div class="p-4 md:p-6 max-w-[1200px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Avisos Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">
Banners no topo da aplicação segmentados por role e contexto.
</p>
</div>
<div class="flex gap-2">
<Button label="Ajuda" icon="pi pi-question-circle" severity="secondary" text @click="showHelp = true" />
<Button label="Novo aviso" icon="pi pi-plus" @click="openCreate" />
</div>
</div>
<!-- Lista -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<div v-else-if="!notices.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-megaphone text-4xl opacity-30 block mb-3" />
Nenhum aviso cadastrado.
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="notice in notices"
:key="notice.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex gap-4 items-start"
:class="{ 'opacity-55': !notice.is_active }"
>
<!-- Variante pill -->
<div class="shrink-0 pt-0.5">
<Tag
:value="VARIANT_LABELS[notice.variant] || notice.variant"
:severity="VARIANT_SEVERITY[notice.variant]"
class="text-[0.7rem]"
/>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span v-if="notice.title" class="font-semibold text-sm">{{ notice.title }}</span>
<span class="text-xs text-[var(--text-color-secondary)] truncate max-w-[320px]"
v-html="notice.message" />
</div>
<div class="flex gap-3 mt-2 flex-wrap text-xs text-[var(--text-color-secondary)]">
<span><b>Prioridade:</b> {{ notice.priority }}</span>
<span><b>Versão:</b> {{ notice.version }}</span>
<span v-if="notice.starts_at"><b>De:</b> {{ fmtDate(notice.starts_at) }}</span>
<span v-if="notice.ends_at"><b>Até:</b> {{ fmtDate(notice.ends_at) }}</span>
<span v-if="notice.roles?.length"><b>Roles:</b> {{ notice.roles.join(', ') }}</span>
<span v-if="notice.contexts?.length"><b>Contextos:</b> {{ notice.contexts.join(', ') }}</span>
<span class="text-[var(--text-color-secondary)] opacity-70">
👁 {{ notice.views_count }} visualizações · 🖱 {{ notice.clicks_count }} cliques
</span>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 shrink-0">
<Button
:icon="notice.is_active ? 'pi pi-eye-slash' : 'pi pi-eye'"
text rounded size="small"
:severity="notice.is_active ? 'secondary' : 'success'"
:title="notice.is_active ? 'Desativar' : 'Ativar'"
@click="toggle(notice)"
/>
<Button
icon="pi pi-pencil"
text rounded size="small"
title="Editar"
@click="openEdit(notice)"
/>
<Button
icon="pi pi-trash"
text rounded size="small"
severity="danger"
title="Excluir"
@click="confirmDelete(notice)"
/>
</div>
</div>
</div>
<!-- Dialog Criar/Editar -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Novo Aviso' : 'Editar Aviso'"
modal
:style="{ width: '680px', maxWidth: '95vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Variante + Título -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Variante *</label>
<Select
v-model="form.variant"
:options="VARIANT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Título (opcional)</label>
<InputText v-model="form.title" class="w-full" placeholder="Ex: Manutenção programada" />
</div>
</div>
<!-- Mensagem + Alinhamento -->
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2 flex-wrap">
<label class="text-xs font-semibold">Mensagem * <span class="font-normal opacity-60">(aceita HTML simples)</span></label>
<SelectButton
v-model="form.content_align"
:options="ALIGN_OPTIONS"
option-value="value"
:allow-empty="false"
size="small"
>
<template #option="{ option }">
<i :class="option.icon" class="text-[0.75rem]" :title="option.label" />
</template>
</SelectButton>
</div>
<Textarea
v-model="form.message"
rows="3"
class="w-full"
placeholder='Ex: O sistema ficará indisponível em <b>15/04 às 02h</b>.'
auto-resize
/>
</div>
<!-- Segmentação -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Roles <span class="font-normal opacity-60">(vazio = todos)</span></label>
<MultiSelect
v-model="form.roles"
:options="ROLE_OPTIONS"
option-label="label"
option-value="value"
placeholder="Todos os roles"
class="w-full"
display="chip"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Contextos <span class="font-normal opacity-60">(vazio = todos)</span></label>
<MultiSelect
v-model="form.contexts"
:options="CONTEXT_OPTIONS"
option-label="label"
option-value="value"
placeholder="Todos os contextos"
class="w-full"
display="chip"
/>
</div>
</div>
<!-- Tempo -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Exibir a partir de</label>
<InputText v-model="form.starts_at" type="datetime-local" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Exibir até</label>
<InputText v-model="form.ends_at" type="datetime-local" class="w-full" />
</div>
</div>
<!-- Prioridade + Versão + Ativo -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Prioridade</label>
<InputNumber v-model="form.priority" :min="0" :max="999" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Versão</label>
<InputNumber v-model="form.version" :min="1" class="w-full" />
</div>
<div class="flex flex-col gap-1.5 justify-end">
<div class="flex items-center gap-2 h-[42px]">
<ToggleSwitch v-model="form.is_active" inputId="sw-active" />
<label for="sw-active" class="text-sm cursor-pointer">Ativo</label>
</div>
</div>
</div>
<!-- Dismiss -->
<div class="border border-[var(--surface-border)] rounded-lg p-3 flex flex-col gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Dismiss</span>
<div class="grid grid-cols-3 gap-3">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.dismissible" inputId="sw-dismissible" />
<label for="sw-dismissible" class="text-sm cursor-pointer">Fechável</label>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.persist_dismiss" inputId="sw-persist" :disabled="!form.dismissible" />
<label for="sw-persist" class="text-sm cursor-pointer">Persistir fechamento</label>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Escopo</label>
<Select
v-model="form.dismiss_scope"
:options="SCOPE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
:disabled="!form.dismissible || !form.persist_dismiss"
/>
</div>
</div>
</div>
<!-- Regras de exibição -->
<div class="border border-[var(--surface-border)] rounded-lg p-3 flex flex-col gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Regras de exibição</span>
<div class="grid grid-cols-3 gap-3">
<div class="flex items-center gap-2 col-span-1">
<ToggleSwitch v-model="form.show_once" inputId="sw-once" />
<label for="sw-once" class="text-sm cursor-pointer">Exibir 1x</label>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Máx. visualizações</label>
<InputNumber v-model="form.max_views" :min="1" placeholder="Ilimitado" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Cooldown (minutos)</label>
<InputNumber v-model="form.cooldown_minutes" :min="1" placeholder="Sem cooldown" class="w-full" />
</div>
</div>
</div>
<!-- CTA -->
<div class="border border-[var(--surface-border)] rounded-lg p-3 flex flex-col gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Ação (CTA)</span>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Tipo de ação</label>
<SelectButton
v-model="form.action_type"
:options="ACTION_OPTIONS"
option-label="label"
option-value="value"
/>
</div>
<!-- CTA externa -->
<div v-if="showCta && form.action_type === 'external'" class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Label do botão</label>
<InputText v-model="form.action_label" class="w-full" placeholder="Ex: Saiba mais" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">URL externa</label>
<InputText v-model="form.action_url" class="w-full" placeholder="https://..." />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Target</label>
<Select
v-model="form.link_target"
:options="TARGET_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
</div>
<!-- CTA interna -->
<div v-if="showCta && form.action_type === 'internal'" class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Label do botão</label>
<InputText v-model="form.action_label" class="w-full" placeholder="Ex: Ver planos" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Rota interna</label>
<Select
v-model="form.action_route"
:options="routeOptions"
option-label="label"
option-value="value"
class="w-full"
filter
filterPlaceholder="Buscar rota..."
placeholder="Selecione uma rota"
:virtualScrollerOptions="{ itemSize: 38 }"
/>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button
:label="dlg.mode === 'create' ? 'Criar aviso' : 'Salvar'"
icon="pi pi-check"
:loading="dlg.saving"
@click="save"
/>
</template>
</Dialog>
<!-- Dialog de Ajuda -->
<Dialog
v-model:visible="showHelp"
header="Como funciona o Aviso Global"
modal
:style="{ width: '680px', maxWidth: '95vw' }"
:draggable="false"
>
<Accordion :value="['variant']" multiple>
<AccordionPanel value="variant">
<AccordionHeader>
<i class="pi pi-palette mr-2 text-[var(--primary-color)]" />
Variante
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Define a cor de fundo do banner: <b>Info</b> (azul), <b>Sucesso</b> (verde), <b>Atenção</b> (âmbar) ou <b>Erro</b> (vermelho).
O ícone lateral muda automaticamente conforme a variante escolhida.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="segmentation">
<AccordionHeader>
<i class="pi pi-users mr-2 text-[var(--primary-color)]" />
Segmentação (Roles e Contextos)
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Roles</b> filtram por perfil do usuário (ex: <code>clinic_admin</code>, <code>therapist</code>). Deixar vazio exibe para todos os perfis.<br/><br/>
<b>Contextos</b> filtram pela área da aplicação em que o usuário está (ex: <code>clinic</code> = <code>/admin</code>, <code>therapist</code> = <code>/therapist</code>). Deixar vazio exibe em qualquer área.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="time">
<AccordionHeader>
<i class="pi pi-calendar mr-2 text-[var(--primary-color)]" />
Controle de tempo
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Exibir a partir de / Exibir até</b>: janela de tempo em que o aviso pode aparecer. Deixar em branco = sem restrição de data.<br/><br/>
O campo <b>Ativo</b> desativa manualmente o aviso independentemente do período configurado.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="priority">
<AccordionHeader>
<i class="pi pi-sort-amount-up mr-2 text-[var(--primary-color)]" />
Prioridade
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Quando mais de um aviso é elegível para o mesmo usuário, apenas o de <b>maior prioridade</b> aparece.
Use números inteiros ex: 10, 50, 100. O padrão é 0 (menor prioridade).
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="dismiss">
<AccordionHeader>
<i class="pi pi-times-circle mr-2 text-[var(--primary-color)]" />
Dismiss (fechamento)
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Fechável</b>: exibe o botão × no banner. Se desativado, o usuário não pode fechar manualmente.<br/><br/>
<b>Persistir fechamento</b>: lembra que o usuário fechou para não reexibir.<br/><br/>
<b>Escopo</b> do dismiss:<br/>
&bull; <b>Dispositivo</b> salvo em <code>localStorage</code> (persiste entre sessões no mesmo navegador).<br/>
&bull; <b>Sessão</b> salvo em <code>sessionStorage</code> (apaga ao fechar o navegador).<br/>
&bull; <b>Usuário</b> salvo no banco de dados (persiste em qualquer dispositivo do mesmo usuário).
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="rules">
<AccordionHeader>
<i class="pi pi-eye mr-2 text-[var(--primary-color)]" />
Regras de exibição
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Exibir 1x</b>: após a primeira visualização, o aviso não aparece mais para aquele usuário/dispositivo.<br/><br/>
<b>Máx. visualizações</b>: limita quantas vezes o aviso pode aparecer. Ex: 3 = aparece no máximo 3 vezes.<br/><br/>
<b>Cooldown (minutos)</b>: intervalo mínimo entre exibições. Ex: 60 = reexibe após 1 hora desde a última vez que apareceu.
Útil para avisos recorrentes que não devem incomodar em cada pageload.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="version">
<AccordionHeader>
<i class="pi pi-history mr-2 text-[var(--primary-color)]" />
Versão
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Ao incrementar a <b>Versão</b> de um aviso publicado, todos os dismissals anteriores ficam inválidos
o aviso volta a aparecer para usuários que tinham fechado.
Use ao editar o conteúdo de um aviso importante que precisa ser relido.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="cta">
<AccordionHeader>
<i class="pi pi-arrow-right mr-2 text-[var(--primary-color)]" />
Ação (CTA)
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Nenhuma</b>: banner informativo, sem botão de ação.<br/><br/>
<b>Interna</b>: 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.<br/><br/>
<b>Externa</b>: botão que abre uma URL externa. Defina o <b>Target</b> (<code>_blank</code> = nova aba, padrão).
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="tracking">
<AccordionHeader>
<i class="pi pi-chart-bar mr-2 text-[var(--primary-color)]" />
Rastreamento
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Cada aviso acumula <b>visualizações</b> e <b>cliques no CTA</b> de forma agregada (sem identificar o usuário).
Os contadores são exibidos na listagem e incrementados via RPC no banco de dados.
</p>
</AccordionContent>
</AccordionPanel>
</Accordion>
<template #footer>
<Button label="Fechar" @click="showHelp = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>