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
|
-- FIX: agendador_slots_disponiveis + agendador_dias_disponiveis
|
||||||
-- Usa agenda_online_slots como fonte de slots
|
-- 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
|
-- Execute no Supabase SQL Editor
|
||||||
-- ═══════════════════════════════════════════════════════════════════════════
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@@ -42,6 +43,22 @@ BEGIN
|
|||||||
v_agora := now();
|
v_agora := now();
|
||||||
v_db_dow := extract(dow from p_data::timestamp)::int;
|
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
|
FOR v_slot IN
|
||||||
SELECT s.time
|
SELECT s.time
|
||||||
FROM public.agenda_online_slots s
|
FROM public.agenda_online_slots s
|
||||||
@@ -60,6 +77,23 @@ BEGIN
|
|||||||
v_ocupado := true;
|
v_ocupado := true;
|
||||||
END IF;
|
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) ────────────────────────────
|
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
|
||||||
IF NOT v_ocupado THEN
|
IF NOT v_ocupado THEN
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
@@ -73,7 +107,6 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
|
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
|
||||||
-- Loop explícito para evitar erros de tipo no cálculo do ciclo semanal
|
|
||||||
IF NOT v_ocupado THEN
|
IF NOT v_ocupado THEN
|
||||||
FOR v_rule IN
|
FOR v_rule IN
|
||||||
SELECT
|
SELECT
|
||||||
@@ -92,16 +125,12 @@ BEGIN
|
|||||||
AND r.start_time::time < v_slot_fim
|
AND r.start_time::time < v_slot_fim
|
||||||
AND r.end_time::time > v_slot
|
AND r.end_time::time > v_slot
|
||||||
LOOP
|
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_rule_start_dow := extract(dow from v_rule.start_date)::int;
|
||||||
v_first_occ := v_rule.start_date
|
v_first_occ := v_rule.start_date
|
||||||
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
||||||
v_day_diff := (p_data - v_first_occ)::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
|
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;
|
v_ex_type := NULL;
|
||||||
SELECT ex.type INTO v_ex_type
|
SELECT ex.type INTO v_ex_type
|
||||||
FROM public.recurrence_exceptions ex
|
FROM public.recurrence_exceptions ex
|
||||||
@@ -109,21 +138,19 @@ BEGIN
|
|||||||
AND ex.original_date = p_data
|
AND ex.original_date = p_data
|
||||||
LIMIT 1;
|
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 (
|
IF v_ex_type IS NULL OR v_ex_type NOT IN (
|
||||||
'cancel_session', 'patient_missed',
|
'cancel_session', 'patient_missed',
|
||||||
'therapist_canceled', 'holiday_block',
|
'therapist_canceled', 'holiday_block',
|
||||||
'reschedule_session'
|
'reschedule_session'
|
||||||
) THEN
|
) THEN
|
||||||
v_ocupado := true;
|
v_ocupado := true;
|
||||||
EXIT; -- já basta uma regra que conflite
|
EXIT;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- ── Recorrências remarcadas para este dia (reschedule → new_date = p_data) ─
|
-- ── Recorrências remarcadas para este dia ────────────────────────────────
|
||||||
IF NOT v_ocupado THEN
|
IF NOT v_ocupado THEN
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -180,6 +207,7 @@ DECLARE
|
|||||||
v_data_fim date;
|
v_data_fim date;
|
||||||
v_db_dow int;
|
v_db_dow int;
|
||||||
v_tem_slot boolean;
|
v_tem_slot boolean;
|
||||||
|
v_bloqueado boolean;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT c.owner_id, c.antecedencia_minima_horas
|
SELECT c.owner_id, c.antecedencia_minima_horas
|
||||||
INTO v_owner_id, v_antecedencia
|
INTO v_owner_id, v_antecedencia
|
||||||
@@ -197,6 +225,25 @@ BEGIN
|
|||||||
WHILE v_data <= v_data_fim LOOP
|
WHILE v_data <= v_data_fim LOOP
|
||||||
v_db_dow := extract(dow from v_data::timestamp)::int;
|
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 EXISTS (
|
||||||
SELECT 1 FROM public.agenda_online_slots s
|
SELECT 1 FROM public.agenda_online_slots s
|
||||||
WHERE s.owner_id = v_owner_id
|
WHERE s.owner_id = v_owner_id
|
||||||
|
|||||||
@@ -1089,6 +1089,24 @@
|
|||||||
v-tooltip.bottom="'Remover'"
|
v-tooltip.bottom="'Remover'"
|
||||||
@click="onDelete"
|
@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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
label="Salvar"
|
label="Salvar"
|
||||||
@@ -1120,6 +1138,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch, nextTick } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
|
import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import Select from 'primevue/select'
|
import Select from 'primevue/select'
|
||||||
import Textarea from 'primevue/textarea'
|
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) {
|
function labelStatusSessao (v) {
|
||||||
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado' }
|
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado' }
|
||||||
return map[v] || '—'
|
return map[v] || '—'
|
||||||
@@ -3419,4 +3461,31 @@ function statusSeverity (v) {
|
|||||||
margin-top: .25rem;
|
margin-top: .25rem;
|
||||||
border-top: 1px solid var(--surface-border);
|
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>
|
</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 })
|
||||||
|
}
|
||||||
@@ -10,7 +10,13 @@ import AppRail from './AppRail.vue'
|
|||||||
import AppRailPanel from './AppRailPanel.vue'
|
import AppRailPanel from './AppRailPanel.vue'
|
||||||
import AppRailSidebar from './AppRailSidebar.vue'
|
import AppRailSidebar from './AppRailSidebar.vue'
|
||||||
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
||||||
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
|
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
|
||||||
|
import GlobalNoticeBanner from '@/features/notices/GlobalNoticeBanner.vue'
|
||||||
|
|
||||||
|
import { useNoticeStore } from '@/stores/noticeStore'
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore'
|
||||||
|
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||||
|
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||||
|
|
||||||
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
|
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
|
||||||
|
|
||||||
@@ -21,19 +27,23 @@ const ajudaPushStyle = computed(() => ({
|
|||||||
paddingRight: drawerOpen.value ? '420px' : '0'
|
paddingRight: drawerOpen.value ? '420px' : '0'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { useTenantStore } from '@/stores/tenantStore'
|
const route = useRoute()
|
||||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
const noticeStore = useNoticeStore()
|
||||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
|
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
|
||||||
|
|
||||||
const layoutArea = computed(() => route.meta?.area || null)
|
const layoutArea = computed(() => route.meta?.area || null)
|
||||||
provide('layoutArea', layoutArea)
|
provide('layoutArea', layoutArea)
|
||||||
|
|
||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
const entitlementsStore = useEntitlementsStore()
|
const entitlementsStore = useEntitlementsStore()
|
||||||
const tf = useTenantFeaturesStore()
|
const tf = useTenantFeaturesStore()
|
||||||
|
|
||||||
|
// ── Atualiza contexto dos notices ao mudar de rota ────────────
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => noticeStore.updateContext(path, tenantStore.role),
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
const containerClass = computed(() => {
|
const containerClass = computed(() => {
|
||||||
return {
|
return {
|
||||||
@@ -86,6 +96,7 @@ watch(() => route.path, (path) => fetchDocsForPath(path), { immediate: true })
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('app:session-refreshed', onSessionRefreshed)
|
window.addEventListener('app:session-refreshed', onSessionRefreshed)
|
||||||
|
noticeStore.init(tenantStore.role, route.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -146,6 +157,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- ══ Global — fora de todos os branches, persiste em qualquer layout/rota ══ -->
|
<!-- ══ Global — fora de todos os branches, persiste em qualquer layout/rota ══ -->
|
||||||
<SupportDebugBanner />
|
<SupportDebugBanner />
|
||||||
|
<GlobalNoticeBanner />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -154,14 +166,24 @@ onBeforeUnmount(() => {
|
|||||||
para sobrescrever o tema PrimeVue/Sakai
|
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) ────────
|
/* ── Sidebar — sempre abaixo da topbar fixed (56px) ────────
|
||||||
z-index: 999 para flutuar sobre o conteúdo em overlay.
|
Desce pelo banner também. */
|
||||||
Topbar (z-index 1000) fica sempre acessível acima da sidebar. */
|
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 56px !important;
|
top: calc(56px + var(--notice-banner-height)) !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
height: calc(100vh - 56px) !important;
|
height: calc(100vh - 56px - var(--notice-banner-height)) !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
box-shadow: 2px 0 6px rgba(0,0,0,.06) !important;
|
box-shadow: 2px 0 6px rgba(0,0,0,.06) !important;
|
||||||
@@ -181,7 +203,7 @@ onBeforeUnmount(() => {
|
|||||||
.layout-main-container {
|
.layout-main-container {
|
||||||
margin-left: 20rem !important;
|
margin-left: 20rem !important;
|
||||||
padding-left: 0 !important;
|
padding-left: 0 !important;
|
||||||
padding-top: 56px !important;
|
padding-top: calc(56px + var(--notice-banner-height)) !important;
|
||||||
}
|
}
|
||||||
.layout-overlay .layout-main-container,
|
.layout-overlay .layout-main-container,
|
||||||
.layout-static-inactive .layout-main-container {
|
.layout-static-inactive .layout-main-container {
|
||||||
@@ -203,11 +225,14 @@ onBeforeUnmount(() => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ─── Layout Rail (inalterado) ────────────────── */
|
/* ─── Layout Rail ─────────────────────────────── */
|
||||||
.l2-root {
|
.l2-root {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--notice-banner-height, 0px); /* desce pelo banner */
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface-ground);
|
background: var(--surface-ground);
|
||||||
}
|
}
|
||||||
@@ -217,9 +242,9 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-top: 56px; /* compensa a topbar fixed */
|
padding-top: 56px; /* compensa topbar — banner já absorvido pelo l2-root */
|
||||||
}
|
}
|
||||||
|
|
||||||
.l2-content {
|
.l2-content {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- src/layout/AppTopbar.vue -->
|
<!-- src/layout/AppTopbar.vue -->
|
||||||
<script setup>
|
<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 { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useLayout } from '@/layout/composables/layout'
|
import { useLayout } from '@/layout/composables/layout'
|
||||||
@@ -31,6 +31,7 @@ import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersist
|
|||||||
import { applyThemeEngine } from '@/theme/theme.options'
|
import { applyThemeEngine } from '@/theme/theme.options'
|
||||||
|
|
||||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||||
|
import { fetchAllNotices } from '@/features/notices/noticeService'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const entitlementsStore = useEntitlementsStore()
|
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
|
Logout
|
||||||
----------------------------- */
|
----------------------------- */
|
||||||
@@ -536,6 +552,20 @@ onMounted(async () => {
|
|||||||
<span>Agência PSI</span>
|
<span>Agência PSI</span>
|
||||||
</router-link>
|
</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 -->
|
<!-- Pills: visíveis apenas em > 1200px -->
|
||||||
<div class="topbar-ctx-row ml-2">
|
<div class="topbar-ctx-row ml-2">
|
||||||
<span
|
<span
|
||||||
@@ -661,7 +691,7 @@ onMounted(async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.rail-topbar {
|
.rail-topbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: var(--notice-banner-height, 0px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -726,6 +756,52 @@ onMounted(async () => {
|
|||||||
z-index: 200;
|
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 */
|
/* Badge de notificações */
|
||||||
.rail-topbar__notification-badge {
|
.rail-topbar__notification-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -77,6 +77,22 @@ const secoes = [
|
|||||||
to: '/configuracoes/convenios',
|
to: '/configuracoes/convenios',
|
||||||
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
|
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(() => {
|
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">
|
<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) -->
|
<!-- 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-wrap">
|
||||||
<div class="cfg-sidebar-head">
|
<div class="cfg-sidebar-head">
|
||||||
<i class="pi pi-cog text-xs opacity-60" />
|
<i class="pi pi-cog text-xs opacity-60" />
|
||||||
@@ -278,6 +294,13 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
|||||||
color: var(--primary-color, #6366f1);
|
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 ──────────────────────────────────────── */
|
/* ── Sidebar nav ──────────────────────────────────────── */
|
||||||
.cfg-sidebar-wrap {
|
.cfg-sidebar-wrap {
|
||||||
border: 1px solid var(--surface-border);
|
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)
|
||||||
@@ -66,8 +66,10 @@ export default function saasMenu (sessionCtx, opts = {}) {
|
|||||||
to: '/saas/docs',
|
to: '/saas/docs',
|
||||||
...docsBadge
|
...docsBadge
|
||||||
},
|
},
|
||||||
{ label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' },
|
{ 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',
|
path: 'convenios',
|
||||||
name: 'ConfiguracoesConvenios',
|
name: 'ConfiguracoesConvenios',
|
||||||
component: () => import('@/layout/configuracoes/ConfiguracoesConveniosPage.vue')
|
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',
|
path: 'login-carousel',
|
||||||
name: 'saas-login-carousel',
|
name: 'saas-login-carousel',
|
||||||
component: () => import('@/views/pages/saas/SaasLoginCarousel.vue')
|
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 { supabase } from '@/lib/supabase/client'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import DatePicker from 'primevue/datepicker'
|
import DatePicker from 'primevue/datepicker'
|
||||||
|
import { getFeriadosNacionais } from '@/utils/feriadosBR'
|
||||||
|
import { createNotice, deleteNotice } from '@/features/notices/noticeService'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -54,12 +56,14 @@ async function salvar () {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const { data: me } = await supabase.auth.getUser()
|
const { data: me } = await supabase.auth.getUser()
|
||||||
|
const isoData = dateToISO(form.value.data)
|
||||||
|
const tenantId = form.value.tenant_id || null
|
||||||
const payload = {
|
const payload = {
|
||||||
owner_id: me?.user?.id || null,
|
owner_id: me?.user?.id || null,
|
||||||
tenant_id: form.value.tenant_id || null,
|
tenant_id: tenantId,
|
||||||
tipo: 'municipal',
|
tipo: 'municipal',
|
||||||
nome: form.value.nome.trim(),
|
nome: form.value.nome.trim(),
|
||||||
data: dateToISO(form.value.data),
|
data: isoData,
|
||||||
cidade: form.value.cidade.trim() || null,
|
cidade: form.value.cidade.trim() || null,
|
||||||
estado: form.value.estado.trim() || null,
|
estado: form.value.estado.trim() || null,
|
||||||
observacao: form.value.observacao.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()
|
const { data, error } = await supabase.from('feriados').insert(payload).select('*, tenants(name)').single()
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
|
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
|
||||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
|
||||||
|
// ── Auto-aviso global: somente para feriados globais (tenant_id = null) ──
|
||||||
|
if (!tenantId && isoData) {
|
||||||
|
try {
|
||||||
|
await createNotice({
|
||||||
|
title: form.value.nome.trim(),
|
||||||
|
message: `📅 Lembrete: <b>${form.value.nome.trim()}</b> — ${fmtDate(isoData)} é feriado. Organize sua agenda com antecedência.`,
|
||||||
|
variant: 'info',
|
||||||
|
starts_at: dateMinus2(isoData),
|
||||||
|
ends_at: `${isoData}T23:59`,
|
||||||
|
is_active: true,
|
||||||
|
priority: 10,
|
||||||
|
dismissible: true,
|
||||||
|
persist_dismiss: true,
|
||||||
|
dismiss_scope: 'device',
|
||||||
|
content_align: 'center',
|
||||||
|
action_type: 'none',
|
||||||
|
roles: [],
|
||||||
|
contexts: [],
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Feriado cadastrado', detail: 'Aviso global criado automaticamente.', life: 3000 })
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'success', summary: 'Feriado cadastrado', detail: 'Aviso global não pôde ser criado — crie manualmente em Avisos Globais.', life: 4000 })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||||
|
}
|
||||||
|
|
||||||
dlgOpen.value = false
|
dlgOpen.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||||
@@ -102,11 +133,11 @@ async function loadTenants () {
|
|||||||
tenants.value = data || []
|
tenants.value = data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { load(); loadTenants() })
|
onMounted(() => { load(); loadTenants(); carregarDeclinados() })
|
||||||
|
|
||||||
// ── Navegação de ano ─────────────────────────────────────────
|
// ── Navegação de ano ─────────────────────────────────────────
|
||||||
async function anoAnterior () { ano.value--; await load() }
|
async function anoAnterior () { ano.value--; carregarDeclinados(); await load() }
|
||||||
async function anoProximo () { ano.value++; await load() }
|
async function anoProximo () { ano.value++; carregarDeclinados(); await load() }
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
function fmtDate (iso) {
|
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 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)
|
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 ───────────────────────────────────────────────────
|
// ── Excluir ───────────────────────────────────────────────────
|
||||||
async function excluir (id) {
|
async function excluir (id) {
|
||||||
try {
|
try {
|
||||||
@@ -210,7 +433,135 @@ async function excluir (id) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- 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 ──────────────────────────────────────────── -->
|
<!-- ── Stats ──────────────────────────────────────────── -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
@@ -303,7 +654,90 @@ async function excluir (id) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div><!-- /municipais -->
|
||||||
|
</div><!-- /content -->
|
||||||
|
|
||||||
|
<!-- ══ Dialog despublicação ══════════════════════════════ -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dlgUnpublish"
|
||||||
|
modal
|
||||||
|
:draggable="false"
|
||||||
|
:style="{ width: '420px' }"
|
||||||
|
header="Despublicar feriado"
|
||||||
|
>
|
||||||
|
<div v-if="feriadoParaDespublicar" class="flex flex-col gap-4 pt-1">
|
||||||
|
<div class="flex items-center gap-3 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
|
||||||
|
<i class="pi pi-exclamation-triangle text-red-500 text-lg shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm m-0">{{ feriadoParaDespublicar.nome }}</p>
|
||||||
|
<p class="text-xs text-[var(--text-color-secondary)] m-0">{{ fmtDate(feriadoParaDespublicar.data) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="text-sm flex flex-col gap-1.5 m-0 pl-0 list-none">
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<i class="pi pi-times text-red-500 mt-0.5 shrink-0" />
|
||||||
|
O feriado será removido da agenda de <b>todos os tenants</b>.
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<i class="pi pi-times text-red-500 mt-0.5 shrink-0" />
|
||||||
|
O aviso global associado a este feriado será <b>excluído automaticamente</b>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" severity="secondary" outlined @click="dlgUnpublish = false" />
|
||||||
|
<Button
|
||||||
|
label="Despublicar"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
:loading="despublicando"
|
||||||
|
@click="confirmarDespublicacao"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- ══ Dialog confirmação final — publicar nacional ══════ -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dlgPublicar"
|
||||||
|
modal
|
||||||
|
:draggable="false"
|
||||||
|
:style="{ width: '440px' }"
|
||||||
|
header="Confirmar publicação"
|
||||||
|
>
|
||||||
|
<div v-if="feriadoParaPublicar" class="flex flex-col gap-4 pt-1">
|
||||||
|
<div class="flex items-center gap-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-4 py-3">
|
||||||
|
<i class="pi pi-flag text-amber-500 text-lg shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm m-0">{{ feriadoParaPublicar.nome }}</p>
|
||||||
|
<p class="text-xs text-[var(--text-color-secondary)] m-0">{{ fmtDate(feriadoParaPublicar.data) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="text-sm flex flex-col gap-1.5 m-0 pl-0 list-none">
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<i class="pi pi-users text-blue-500 mt-0.5 shrink-0" />
|
||||||
|
O feriado ficará visível na agenda de <b>todos os tenants</b>.
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<i class="pi pi-megaphone text-blue-500 mt-0.5 shrink-0" />
|
||||||
|
Um <b>aviso global</b> será criado automaticamente (você pode editar depois em Avisos Globais).
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
|
||||||
|
<span class="text-[var(--text-color-secondary)]">Sessões <b>não</b> serão bloqueadas automaticamente — cada usuário decide bloquear individualmente.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" severity="secondary" outlined @click="dlgPublicar = false" />
|
||||||
|
<Button
|
||||||
|
label="Confirmar publicação"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="warn"
|
||||||
|
:loading="!!salvandoNacional"
|
||||||
|
@click="confirmarPublicacao"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- ══ Dialog cadastro ════════════════════════════════════ -->
|
<!-- ══ Dialog cadastro ════════════════════════════════════ -->
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -380,3 +814,87 @@ async function excluir (id) {
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</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