Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
296
src/features/notices/GlobalNoticeBanner.vue
Normal file
296
src/features/notices/GlobalNoticeBanner.vue
Normal 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 só 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>
|
||||
116
src/features/notices/noticeService.js
Normal file
116
src/features/notices/noticeService.js
Normal 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 })
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import AppRailPanel from './AppRailPanel.vue'
|
||||
import AppRailSidebar from './AppRailSidebar.vue'
|
||||
import AjudaDrawer from '@/components/AjudaDrawer.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,11 +27,8 @@ 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 noticeStore = useNoticeStore()
|
||||
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
|
||||
|
||||
const layoutArea = computed(() => route.meta?.area || null)
|
||||
@@ -35,6 +38,13 @@ const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
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 {
|
||||
'layout-overlay': layoutConfig.menuMode === 'overlay',
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 — já 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);
|
||||
|
||||
850
src/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue
Normal file
850
src/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue
Normal 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">Só 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">Só 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>
|
||||
706
src/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue
Normal file
706
src/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue
Normal 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>
|
||||
74
src/lib/email/emailTemplateConstants.js
Normal file
74
src/lib/email/emailTemplateConstants.js
Normal 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',
|
||||
},
|
||||
}
|
||||
335
src/lib/email/emailTemplateService.js
Normal file
335
src/lib/email/emailTemplateService.js
Normal 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
706
src/lib/email/emailTemplates.reference.js
Normal file
706
src/lib/email/emailTemplates.reference.js
Normal 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)
|
||||
@@ -67,7 +67,9 @@ export default function saasMenu (sessionCtx, opts = {}) {
|
||||
...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: '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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
139
src/sql-arquivos/global_notices.sql
Normal file
139
src/sql-arquivos/global_notices.sql
Normal 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
225
src/stores/noticeStore.js
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
58
src/utils/googleCalendarLink.js
Normal file
58
src/utils/googleCalendarLink.js
Normal 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()}`
|
||||
}
|
||||
353
src/views/pages/saas/SaasEmailTemplatesPage.vue
Normal file
353
src/views/pages/saas/SaasEmailTemplatesPage.vue
Normal 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>{{variavel}}</code> e
|
||||
<code>{{#if variavel}}...{{/if}}</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>
|
||||
@@ -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))
|
||||
|
||||
// ── 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><!-- /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>
|
||||
|
||||
685
src/views/pages/saas/SaasGlobalNoticesPage.vue
Normal file
685
src/views/pages/saas/SaasGlobalNoticesPage.vue
Normal 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 só 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/>
|
||||
• <b>Dispositivo</b> — salvo em <code>localStorage</code> (persiste entre sessões no mesmo navegador).<br/>
|
||||
• <b>Sessão</b> — salvo em <code>sessionStorage</code> (apaga ao fechar o navegador).<br/>
|
||||
• <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 só 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 só 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 já publicado, todos os dismissals anteriores ficam inválidos —
|
||||
o aviso volta a aparecer para usuários que já tinham fechado.
|
||||
Use ao editar o conteúdo de um aviso importante que precisa ser relido.
|
||||
</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 só 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>
|
||||
Reference in New Issue
Block a user