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

This commit is contained in:
Leonardo
2026-03-18 15:47:37 -03:00
parent d6d2fe29d1
commit 29ed349cf2
21 changed files with 5366 additions and 41 deletions
@@ -0,0 +1,353 @@
<!-- src/views/pages/saas/SaasEmailTemplatesPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Editor from 'primevue/editor'
import { supabase } from '@/lib/supabase/client'
import { renderEmail } from '@/lib/email/emailTemplateService'
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
const toast = useToast()
// ── Perfil (logo no preview) ───────────────────────────────────
const profileLogoUrl = ref(null)
async function loadProfile() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', user.id)
.maybeSingle()
profileLogoUrl.value = data?.avatar_url || null
}
// ── Lista ──────────────────────────────────────────────────────
const templates = ref([])
const loading = ref(false)
const filterDomain = ref(null)
async function load() {
loading.value = true
try {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.order('domain')
.order('key')
if (error) throw error
templates.value = data
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
}
const filtered = computed(() => {
if (!filterDomain.value) return templates.value
return templates.value.filter(t => t.domain === filterDomain.value)
})
const DOMAIN_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro',value: TEMPLATE_DOMAINS.BILLING },
]
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
// ── Dialog edição ──────────────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null })
const form = ref({})
const editorRef = ref(null)
function openEdit(t) {
form.value = {
key: t.key,
subject: t.subject,
body_html: t.body_html,
body_text: t.body_text ?? '',
version: t.version,
is_active: t.is_active,
variables: t.variables || {},
}
dlg.value = { open: true, saving: false, id: t.id }
}
function closeDlg() { dlg.value.open = false }
const formVariables = computed(() => {
const keys = Object.keys(form.value.variables || {})
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
return keys
})
// Insere {{varName}} na posição do cursor no Editor (Quill)
function insertVar(varName) {
const snippet = `{{${varName}}}`
const quill = editorRef.value?.quill
if (!quill) {
form.value.body_html = (form.value.body_html || '') + snippet
return
}
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength() - 1
quill.insertText(index, snippet, 'user')
quill.setSelection(index + snippet.length, 0)
}
async function save() {
if (!form.value.subject?.trim() || !form.value.body_html?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject e body são obrigatórios', life: 3000 })
return
}
dlg.value.saving = true
try {
const { error } = await supabase
.from('email_templates_global')
.update({
subject: form.value.subject,
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: form.value.version,
is_active: form.value.is_active,
})
.eq('id', dlg.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 })
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
}
async function toggleActive(t) {
try {
const { error } = await supabase
.from('email_templates_global')
.update({ is_active: !t.is_active })
.eq('id', t.id)
if (error) throw error
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
// ── Dialog preview ─────────────────────────────────────────────
const preview = ref({ open: false, subject: '', body_html: '', key: '' })
function openPreview(t) {
const mock = {
..._mockForDomain(t.domain),
clinic_logo_url: profileLogoUrl.value || null,
}
const rendered = renderEmail(t, mock)
preview.value = { open: true, ...rendered, key: t.key }
}
function _mockForDomain(domain) {
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
return { ...MOCK_DATA.system }
}
onMounted(() => {
load()
loadProfile()
})
</script>
<template>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">
Templates base do sistema. Tenants podem criar overrides sem alterar estes.
</p>
</div>
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
</div>
<!-- Filtro -->
<div class="flex gap-2 mb-4 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="t in filtered"
:key="t.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
:class="{ 'opacity-50': !t.is_active }"
>
<Tag
:value="DOMAIN_LABEL[t.domain] ?? t.domain"
:severity="DOMAIN_SEVERITY[t.domain] ?? 'secondary'"
class="text-[0.7rem] shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-[var(--text-color-secondary)] mb-0.5">{{ t.key }}</div>
<div class="text-sm truncate font-medium">{{ t.subject }}</div>
</div>
<div class="hidden md:flex items-center gap-3 text-xs text-[var(--text-color-secondary)] shrink-0">
<span>v{{ t.version }}</span>
<Tag :value="t.channel" severity="secondary" class="text-[0.65rem]" />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(t)" />
<Button icon="pi pi-pencil" text rounded size="small" title="Editar" @click="openEdit(t)" />
<Button
:icon="t.is_active ? 'pi pi-eye-slash' : 'pi pi-eye'"
text rounded size="small"
:severity="t.is_active ? 'secondary' : 'success'"
:title="t.is_active ? 'Desativar' : 'Ativar'"
@click="toggleActive(t)"
/>
</div>
</div>
<div v-if="!filtered.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-envelope text-4xl opacity-30 block mb-3" />
Nenhum template neste domínio.
</div>
</div>
<!-- Dialog Edição -->
<Dialog
v-model:visible="dlg.open"
:header="`Editar — ${form.key}`"
modal
:style="{ width: '860px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Subject -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Subject *</label>
<InputText v-model="form.subject" class="w-full" />
</div>
<!-- Body HTML Editor Quill -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Body HTML *</label>
<Editor
ref="editorRef"
v-model="form.body_html"
editor-style="min-height: 260px; font-size: 0.85rem;"
/>
<!-- Botões de variáveis -->
<div class="flex flex-col gap-1.5 mt-1">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables"
:key="v"
:label="`{{${v}}}`"
size="small"
severity="secondary"
outlined
class="font-mono !text-[0.68rem] !py-1 !px-2"
@click="insertVar(v)"
/>
</div>
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0">
Suporta <code>&#123;&#123;variavel&#125;&#125;</code> e
<code>&#123;&#123;#if variavel&#125;&#125;...&#123;&#123;/if&#125;&#125;</code>
</p>
</div>
<!-- Body Text -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">
Body Text
<span class="font-normal opacity-60">(opcional gerado do HTML se vazio)</span>
</label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
</div>
<!-- Versão (esquerda) + Ativo (direita) -->
<div class="flex items-end justify-between">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Versão</label>
<InputNumber v-model="form.version" :min="1" style="width:110px" />
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-global" />
<label for="sw-active-global" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button label="Salvar" icon="pi pi-check" :loading="dlg.saving" @click="save" />
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog
v-model:visible="preview.open"
:header="`Preview — ${preview.key}`"
modal
:style="{ width: '700px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800">
<div v-if="profileLogoUrl" class="mb-4 text-center">
<img :src="profileLogoUrl" alt="Logo" class="h-10 object-contain inline-block" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="text-sm leading-relaxed" v-html="preview.body_html" />
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
Preview com dados de exemplo.
<span v-if="profileLogoUrl"> Logo do seu perfil.</span>
<span v-else> Sem logo configure em <b>Meu Perfil Avatar</b>.</span>
</p>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
</div>
</template>
+526 -8
View File
@@ -5,6 +5,8 @@ import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import DatePicker from 'primevue/datepicker'
import { getFeriadosNacionais } from '@/utils/feriadosBR'
import { createNotice, deleteNotice } from '@/features/notices/noticeService'
const toast = useToast()
@@ -54,12 +56,14 @@ async function salvar () {
saving.value = true
try {
const { data: me } = await supabase.auth.getUser()
const isoData = dateToISO(form.value.data)
const tenantId = form.value.tenant_id || null
const payload = {
owner_id: me?.user?.id || null,
tenant_id: form.value.tenant_id || null,
tenant_id: tenantId,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: dateToISO(form.value.data),
data: isoData,
cidade: form.value.cidade.trim() || null,
estado: form.value.estado.trim() || null,
observacao: form.value.observacao.trim() || null,
@@ -68,7 +72,34 @@ async function salvar () {
const { data, error } = await supabase.from('feriados').insert(payload).select('*, tenants(name)').single()
if (error) throw error
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
// ── Auto-aviso global: somente para feriados globais (tenant_id = null) ──
if (!tenantId && isoData) {
try {
await createNotice({
title: form.value.nome.trim(),
message: `📅 Lembrete: <b>${form.value.nome.trim()}</b> — ${fmtDate(isoData)} é feriado. Organize sua agenda com antecedência.`,
variant: 'info',
starts_at: dateMinus2(isoData),
ends_at: `${isoData}T23:59`,
is_active: true,
priority: 10,
dismissible: true,
persist_dismiss: true,
dismiss_scope: 'device',
content_align: 'center',
action_type: 'none',
roles: [],
contexts: [],
})
toast.add({ severity: 'success', summary: 'Feriado cadastrado', detail: 'Aviso global criado automaticamente.', life: 3000 })
} catch {
toast.add({ severity: 'success', summary: 'Feriado cadastrado', detail: 'Aviso global não pôde ser criado — crie manualmente em Avisos Globais.', life: 4000 })
}
} else {
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
}
dlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
@@ -102,11 +133,11 @@ async function loadTenants () {
tenants.value = data || []
}
onMounted(() => { load(); loadTenants() })
onMounted(() => { load(); loadTenants(); carregarDeclinados() })
// ── Navegação de ano ─────────────────────────────────────────
async function anoAnterior () { ano.value--; await load() }
async function anoProximo () { ano.value++; await load() }
async function anoAnterior () { ano.value--; carregarDeclinados(); await load() }
async function anoProximo () { ano.value++; carregarDeclinados(); await load() }
// ── Helpers ──────────────────────────────────────────────────
function fmtDate (iso) {
@@ -161,6 +192,198 @@ const totalFeriados = computed(() => feriados.value.length)
const totalTenants = computed(() => new Set(feriados.value.map(f => f.tenant_id).filter(Boolean)).size)
const totalMunicipios = computed(() => new Set(feriados.value.map(f => f.cidade).filter(Boolean)).size)
// ── Feriados Nacionais (algoritmo) ────────────────────────────
const nacionais = computed(() => getFeriadosNacionais(ano.value))
function isPassado (iso) {
return iso < new Date().toISOString().slice(0, 10)
}
function dateMinus2 (iso) {
const d = new Date(iso + 'T12:00:00')
d.setDate(d.getDate() - 2)
return `${d.toISOString().slice(0, 10)}T08:00`
}
// Datas de nacionais já publicados no DB como feriado global (tenant_id = null)
const publicadosDatas = computed(() =>
new Set(feriados.value.filter(f => f.tenant_id === null).map(f => f.data))
)
// Datas marcadas como "não publicar" — persiste em localStorage por ano
function lsKey () { return `saas_feriados_declinados_${ano.value}` }
function carregarDeclinados () {
try {
const raw = localStorage.getItem(lsKey())
declinadosDatas.value = new Set(raw ? JSON.parse(raw) : [])
} catch {
declinadosDatas.value = new Set()
}
}
function salvarDeclinados () {
localStorage.setItem(lsKey(), JSON.stringify([...declinadosDatas.value]))
}
const declinadosDatas = ref(new Set())
// Estado de cada feriado: 'published' | 'declined' | 'idle'
function estadoNacional (iso) {
if (publicadosDatas.value.has(iso)) return 'published'
if (declinadosDatas.value.has(iso)) return 'declined'
return 'idle'
}
// ── Fluxo de publicação ───────────────────────────────────────
const confirmandoNacional = ref(null) // ISO exibindo confirmação inline
const salvandoNacional = ref(null) // ISO sendo gravado
const dlgPublicar = ref(false) // Dialog de confirmação final
const feriadoParaPublicar = ref(null) // Objeto feriado aguardando confirmação final
function pedirConfirmacaoNacional (iso) {
confirmandoNacional.value = confirmandoNacional.value === iso ? null : iso
}
async function declinarNacional (feriado) {
const iso = feriado.data
const nome = feriado.nome
const s = new Set(declinadosDatas.value)
s.add(iso)
declinadosDatas.value = s
confirmandoNacional.value = null
salvarDeclinados()
// Remove avisos globais associados (mesmo título + ends_at no dia)
try {
const { data: avisos } = await supabase
.from('global_notices')
.select('id')
.eq('title', nome)
.gte('ends_at', `${iso}T00:00`)
.lte('ends_at', `${iso}T23:59`)
if (avisos?.length) {
await Promise.allSettled(avisos.map(a => deleteNotice(a.id)))
toast.add({ severity: 'info', summary: 'Aviso removido', detail: `Aviso global de "${nome}" excluído.`, life: 3000 })
}
} catch { /* silencioso — declinar já foi salvo */ }
}
function reverterDeclinado (iso) {
const s = new Set(declinadosDatas.value)
s.delete(iso)
declinadosDatas.value = s
salvarDeclinados()
}
function abrirDlgPublicar (feriado) {
feriadoParaPublicar.value = feriado
confirmandoNacional.value = null
dlgPublicar.value = true
}
// ── Despublicação ─────────────────────────────────────────────
const dlgUnpublish = ref(false)
const feriadoParaDespublicar = ref(null)
const despublicando = ref(false)
function abrirDlgUnpublish (feriado) {
// Pega o registro do banco correspondente (tenant_id=null, mesma data)
const registro = feriados.value.find(f => f.tenant_id === null && f.data === feriado.data)
feriadoParaDespublicar.value = { ...feriado, _dbId: registro?.id || null }
dlgUnpublish.value = true
}
async function confirmarDespublicacao () {
const feriado = feriadoParaDespublicar.value
if (!feriado) return
despublicando.value = true
dlgUnpublish.value = false
try {
// 1. Remove o feriado do banco
if (feriado._dbId) {
const { error } = await supabase.from('feriados').delete().eq('id', feriado._dbId)
if (error) throw error
feriados.value = feriados.value.filter(f => f.id !== feriado._dbId)
}
// 2. Remove avisos globais associados (mesmo título + ends_at no dia do feriado)
const { data: avisos } = await supabase
.from('global_notices')
.select('id')
.eq('title', feriado.nome)
.gte('ends_at', `${feriado.data}T00:00`)
.lte('ends_at', `${feriado.data}T23:59`)
await Promise.allSettled((avisos || []).map(a => deleteNotice(a.id)))
toast.add({
severity: 'success',
summary: 'Feriado despublicado',
detail: `${feriado.nome} removido. Aviso(s) global(is) excluído(s).`,
life: 3500
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao despublicar', detail: e?.message, life: 4000 })
} finally {
despublicando.value = false
feriadoParaDespublicar.value = null
}
}
async function confirmarPublicacao () {
const feriado = feriadoParaPublicar.value
if (!feriado) return
salvandoNacional.value = feriado.data
dlgPublicar.value = false
try {
const { data: me } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('feriados')
.insert({
owner_id: me?.user?.id || null,
tenant_id: null,
tipo: 'municipal',
nome: feriado.nome,
data: feriado.data,
bloqueia_sessoes: false, // cada tenant decide bloquear individualmente
})
.select('*, tenants(name)')
.single()
if (error) throw error
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
// Auto-aviso global
try {
await createNotice({
title: feriado.nome,
message: `📅 Lembrete: <b>${feriado.nome}</b> — ${fmtDate(feriado.data)} é feriado nacional. Organize sua agenda com antecedência.`,
variant: 'info',
starts_at: dateMinus2(feriado.data),
ends_at: `${feriado.data}T23:59`,
is_active: true,
priority: 10,
dismissible: true,
persist_dismiss: true,
dismiss_scope: 'device',
content_align: 'center',
action_type: 'none',
roles: [],
contexts: [],
})
} catch { /* silencioso — feriado já foi publicado */ }
toast.add({ severity: 'success', summary: 'Feriado publicado', detail: `${feriado.nome} — aviso global criado automaticamente.`, life: 3500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao publicar', detail: e?.message, life: 4000 })
} finally {
salvandoNacional.value = null
feriadoParaPublicar.value = null
}
}
// ── Excluir ───────────────────────────────────────────────────
async function excluir (id) {
try {
@@ -210,7 +433,135 @@ async function excluir (id) {
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-6">
<!-- Feriados Nacionais -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="flex items-center gap-2 px-5 py-3 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-flag text-blue-500" />
<span class="font-bold text-sm">Feriados Nacionais {{ ano }}</span>
<span class="text-[0.72rem] text-[var(--text-color-secondary)] ml-1">(gerados automaticamente)</span>
</div>
<div class="flex flex-col">
<div
v-for="f in nacionais"
:key="f.data"
class="flex flex-col border-b border-[var(--surface-border)] last:border-b-0"
:class="isPassado(f.data) ? 'opacity-40' : ''"
>
<!-- Linha principal -->
<div
class="flex items-center gap-3 px-5 py-2.5 flex-wrap"
:class="!isPassado(f.data) ? 'hover:bg-[var(--surface-hover)]' : ''"
>
<!-- Data -->
<span class="font-mono text-xs font-semibold bg-[var(--surface-ground)] border border-[var(--surface-border)] rounded px-2 py-0.5 shrink-0">
{{ fmtDate(f.data) }}
</span>
<!-- Nome -->
<span class="flex-1 min-w-[160px] text-sm font-medium" :class="{ 'line-through opacity-50': publicadosDatas.has(f.data) }">
{{ f.nome }}
</span>
<!-- Tags -->
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-[0.68rem]" />
<Tag v-if="isPassado(f.data)" value="Passado" severity="secondary" class="text-[0.68rem] opacity-60" />
<Tag v-if="publicadosDatas.has(f.data)" value="Publicado" severity="success" class="text-[0.68rem]" />
<!-- Ícone de estado -->
<template v-if="!isPassado(f.data)">
<!-- Publicado clicável para despublicar -->
<button
v-if="estadoNacional(f.data) === 'published'"
v-tooltip.top="'Publicado clique para despublicar'"
class="snf-lock snf-lock--published"
@click="abrirDlgUnpublish(f)"
>
<i class="pi pi-lock text-xs" />
</button>
<!-- Salvando / despublicando -->
<span v-else-if="salvandoNacional === f.data || despublicando" class="snf-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Confirmação inline aberta botão fechar -->
<button
v-else-if="confirmandoNacional === f.data"
v-tooltip.top="'Fechar'"
class="snf-lock snf-lock--active"
@click="confirmandoNacional = null"
>
<i class="pi pi-times text-xs" />
</button>
<!-- Declinado círculo X, clicável para reverter -->
<button
v-else-if="estadoNacional(f.data) === 'declined'"
v-tooltip.top="'Marcado como não publicar clique para reverter'"
class="snf-lock snf-lock--declined"
@click="reverterDeclinado(f.data)"
>
<i class="pi pi-times-circle text-xs" />
</button>
<!-- Idle cadeado aberto -->
<button
v-else
v-tooltip.top="'Definir publicação deste feriado'"
class="snf-lock snf-lock--idle"
@click="pedirConfirmacaoNacional(f.data)"
>
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline Publicar ou Não publicar -->
<Transition name="snf-expand">
<div v-if="confirmandoNacional === f.data" class="snf-confirm mx-5 mb-2">
<i class="pi pi-question-circle snf-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">O que fazer com <b>{{ f.nome }}</b>?</p>
<p class="text-xs opacity-70 leading-snug">
Publicar torna o feriado visível na agenda de todos os tenants e cria um aviso global automaticamente.
Não publicar mantém oculto você pode mudar depois.
</p>
</div>
<div class="flex gap-1.5 shrink-0 flex-wrap justify-end">
<Button
label="Não publicar"
size="small"
severity="secondary"
outlined
icon="pi pi-times-circle"
class="rounded-full h-7 text-xs px-3"
@click="declinarNacional(f)"
/>
<Button
label="Sim, publicar"
size="small"
severity="warn"
icon="pi pi-megaphone"
class="rounded-full h-7 text-xs px-3"
@click="abrirDlgPublicar(f)"
/>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Stats municipais -->
<div>
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-star text-amber-500" />
<span class="font-bold text-sm">Feriados Municipais</span>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3">
@@ -303,7 +654,90 @@ async function excluir (id) {
</div>
</template>
</div>
</div><!-- /municipais -->
</div><!-- /content -->
<!-- Dialog despublicação -->
<Dialog
v-model:visible="dlgUnpublish"
modal
:draggable="false"
:style="{ width: '420px' }"
header="Despublicar feriado"
>
<div v-if="feriadoParaDespublicar" class="flex flex-col gap-4 pt-1">
<div class="flex items-center gap-3 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
<i class="pi pi-exclamation-triangle text-red-500 text-lg shrink-0" />
<div>
<p class="font-semibold text-sm m-0">{{ feriadoParaDespublicar.nome }}</p>
<p class="text-xs text-[var(--text-color-secondary)] m-0">{{ fmtDate(feriadoParaDespublicar.data) }}</p>
</div>
</div>
<ul class="text-sm flex flex-col gap-1.5 m-0 pl-0 list-none">
<li class="flex items-start gap-2">
<i class="pi pi-times text-red-500 mt-0.5 shrink-0" />
O feriado será removido da agenda de <b>todos os tenants</b>.
</li>
<li class="flex items-start gap-2">
<i class="pi pi-times text-red-500 mt-0.5 shrink-0" />
O aviso global associado a este feriado será <b>excluído automaticamente</b>.
</li>
</ul>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgUnpublish = false" />
<Button
label="Despublicar"
icon="pi pi-trash"
severity="danger"
:loading="despublicando"
@click="confirmarDespublicacao"
/>
</template>
</Dialog>
<!-- Dialog confirmação final publicar nacional -->
<Dialog
v-model:visible="dlgPublicar"
modal
:draggable="false"
:style="{ width: '440px' }"
header="Confirmar publicação"
>
<div v-if="feriadoParaPublicar" class="flex flex-col gap-4 pt-1">
<div class="flex items-center gap-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-4 py-3">
<i class="pi pi-flag text-amber-500 text-lg shrink-0" />
<div>
<p class="font-semibold text-sm m-0">{{ feriadoParaPublicar.nome }}</p>
<p class="text-xs text-[var(--text-color-secondary)] m-0">{{ fmtDate(feriadoParaPublicar.data) }}</p>
</div>
</div>
<ul class="text-sm flex flex-col gap-1.5 m-0 pl-0 list-none">
<li class="flex items-start gap-2">
<i class="pi pi-users text-blue-500 mt-0.5 shrink-0" />
O feriado ficará visível na agenda de <b>todos os tenants</b>.
</li>
<li class="flex items-start gap-2">
<i class="pi pi-megaphone text-blue-500 mt-0.5 shrink-0" />
Um <b>aviso global</b> será criado automaticamente (você pode editar depois em Avisos Globais).
</li>
<li class="flex items-start gap-2">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
<span class="text-[var(--text-color-secondary)]">Sessões <b>não</b> serão bloqueadas automaticamente cada usuário decide bloquear individualmente.</span>
</li>
</ul>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgPublicar = false" />
<Button
label="Confirmar publicação"
icon="pi pi-check"
severity="warn"
:loading="!!salvandoNacional"
@click="confirmarPublicacao"
/>
</template>
</Dialog>
<!-- Dialog cadastro -->
<Dialog
@@ -380,3 +814,87 @@ async function excluir (id) {
</template>
</Dialog>
</template>
<style scoped>
/* ── Cadeado por feriado nacional ────────────────────────── */
.snf-lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
}
.snf-lock--idle {
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
}
.snf-lock--idle:hover {
color: var(--amber-600, #d97706);
border-color: var(--amber-400, #fbbf24);
background: color-mix(in srgb, var(--amber-400, #fbbf24) 12%, transparent);
}
.snf-lock--active {
color: var(--amber-600, #d97706);
border: 1.5px solid var(--amber-400, #fbbf24);
background: color-mix(in srgb, var(--amber-400, #fbbf24) 14%, transparent);
cursor: pointer;
}
.snf-lock--done {
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
}
.snf-lock--published {
color: var(--green-600, #16a34a);
border: 1.5px solid color-mix(in srgb, var(--green-500, #22c55e) 50%, transparent);
background: color-mix(in srgb, var(--green-500, #22c55e) 10%, transparent);
cursor: pointer;
}
.snf-lock--published:hover {
color: var(--red-500, #ef4444);
border-color: color-mix(in srgb, var(--red-400, #f87171) 50%, transparent);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
}
.snf-lock--declined {
color: var(--red-500, #ef4444);
border: 1.5px solid color-mix(in srgb, var(--red-400, #f87171) 50%, transparent);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
cursor: pointer;
}
.snf-lock--declined:hover {
background: color-mix(in srgb, var(--red-400, #f87171) 20%, transparent);
}
/* ── Confirmação inline ──────────────────────────────────── */
.snf-confirm {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--amber-400, #fbbf24) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--amber-400, #fbbf24) 30%, transparent);
}
.snf-confirm__icon {
color: var(--amber-500, #f59e0b);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
}
/* ── Transição expand ────────────────────────────────────── */
.snf-expand-enter-active,
.snf-expand-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.snf-expand-enter-from,
.snf-expand-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,685 @@
<!-- src/views/pages/saas/SaasGlobalNoticesPage.vue -->
<!-- Painel de gerenciamento de Avisos Globais (SaaS Admin) -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useRouter } from 'vue-router'
import {
fetchAllNotices,
createNotice,
updateNotice,
deleteNotice,
toggleNoticeActive
} from '@/features/notices/noticeService'
const toast = useToast()
const confirm = useConfirm()
const router = useRouter()
// ── Ajuda ─────────────────────────────────────────────────────
const showHelp = ref(false)
// ── Lista ─────────────────────────────────────────────────────
const notices = ref([])
const loading = ref(false)
async function load () {
loading.value = true
try {
notices.value = await fetchAllNotices()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
}
// ── Dialog ────────────────────────────────────────────────────
const EMPTY = () => ({
title: '',
message: '',
variant: 'info',
roles: [],
contexts: [],
starts_at: null,
ends_at: null,
is_active: true,
priority: 0,
dismissible: true,
persist_dismiss: true,
dismiss_scope: 'device',
show_once: false,
max_views: null,
cooldown_minutes: null,
version: 1,
action_type: 'none',
action_label: '',
action_url: '',
action_route: '',
link_target: '_blank',
content_align: 'left'
})
const dlg = ref({ open: false, saving: false, mode: 'create', id: null })
const form = ref(EMPTY())
function openCreate () {
form.value = EMPTY()
dlg.value = { open: true, saving: false, mode: 'create', id: null }
}
function openEdit (notice) {
form.value = {
...EMPTY(),
...notice,
starts_at: notice.starts_at ? notice.starts_at.slice(0, 16) : null,
ends_at: notice.ends_at ? notice.ends_at.slice(0, 16) : null
}
dlg.value = { open: true, saving: false, mode: 'edit', id: notice.id }
}
function closeDlg () {
dlg.value.open = false
}
async function save () {
if (!form.value.message?.trim()) {
toast.add({ severity: 'warn', summary: 'Mensagem obrigatória', life: 3000 })
return
}
dlg.value.saving = true
try {
const payload = {
...form.value,
starts_at: form.value.starts_at || null,
ends_at: form.value.ends_at || null,
max_views: form.value.max_views || null,
cooldown_minutes: form.value.cooldown_minutes || null,
}
if (dlg.value.mode === 'create') {
await createNotice(payload)
toast.add({ severity: 'success', summary: 'Aviso criado', life: 3000 })
} else {
await updateNotice(dlg.value.id, payload)
toast.add({ severity: 'success', summary: 'Aviso salvo', life: 3000 })
}
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
}
// ── Toggle ativo ──────────────────────────────────────────────
async function toggle (notice) {
try {
await toggleNoticeActive(notice.id, !notice.is_active)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
// ── Excluir ───────────────────────────────────────────────────
function confirmDelete (notice) {
confirm.require({
message: `Excluir o aviso "${notice.title || notice.message.slice(0, 40)}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await deleteNotice(notice.id)
toast.add({ severity: 'success', summary: 'Aviso excluído', life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
})
}
// ── Helpers de exibição ───────────────────────────────────────
const VARIANT_LABELS = { info: 'Info', success: 'Sucesso', warning: 'Atenção', error: 'Erro' }
const VARIANT_SEVERITY = { info: 'info', success: 'success', warning: 'warn', error: 'danger' }
const ROLE_OPTIONS = [
{ label: 'Clinic Admin', value: 'clinic_admin' },
{ label: 'Therapist', value: 'therapist' },
{ label: 'Supervisor', value: 'supervisor' },
{ label: 'Editor', value: 'editor' },
{ label: 'Patient', value: 'patient' },
{ label: 'SaaS Admin', value: 'saas_admin' },
]
const CONTEXT_OPTIONS = [
{ label: 'Clínica (/admin)', value: 'clinic' },
{ label: 'Terapeuta (/therapist)', value: 'therapist' },
{ label: 'Supervisor (/supervisor)', value: 'supervisor' },
{ label: 'Editor (/editor)', value: 'editor' },
{ label: 'Portal (/portal)', value: 'portal' },
{ label: 'SaaS (/saas)', value: 'saas' },
{ label: 'Público', value: 'public' },
]
const VARIANT_OPTIONS = [
{ label: 'Info', value: 'info' },
{ label: 'Sucesso', value: 'success' },
{ label: 'Atenção', value: 'warning' },
{ label: 'Erro', value: 'error' },
]
const SCOPE_OPTIONS = [
{ label: 'Dispositivo (localStorage)', value: 'device' },
{ label: 'Sessão (sessionStorage)', value: 'session' },
{ label: 'Usuário (banco)', value: 'user' },
]
const ALIGN_OPTIONS = [
{ label: 'Esquerda', value: 'left', icon: 'pi pi-align-left' },
{ label: 'Centro', value: 'center', icon: 'pi pi-align-center' },
{ label: 'Direita', value: 'right', icon: 'pi pi-align-right' },
{ label: 'Justificado',value: 'justify', icon: 'pi pi-align-justify' },
]
const ACTION_OPTIONS = [
{ label: 'Nenhuma', value: 'none' },
{ label: 'Interna', value: 'internal' },
{ label: 'Externa', value: 'external' },
]
const TARGET_OPTIONS = [
{ label: '_blank (nova aba)', value: '_blank' },
{ label: '_self (mesma aba)', value: '_self' },
{ label: '_parent', value: '_parent' },
{ label: '_top', value: '_top' },
]
// Rotas internas — exclui rotas sem path útil e agrupa por path
const routeOptions = computed(() => {
return router.getRoutes()
.filter(r => r.path && r.path !== '/' && !r.path.includes(':') && r.name)
.map(r => ({ label: `${r.path} (${String(r.name)})`, value: r.path }))
.sort((a, b) => a.value.localeCompare(b.value))
})
const showCta = computed(() => form.value.action_type !== 'none')
// ── Stats formatadas ──────────────────────────────────────────
function fmtDate (iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
}
onMounted(load)
</script>
<template>
<div class="p-4 md:p-6 max-w-[1200px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Avisos Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">
Banners no topo da aplicação segmentados por role e contexto.
</p>
</div>
<div class="flex gap-2">
<Button label="Ajuda" icon="pi pi-question-circle" severity="secondary" text @click="showHelp = true" />
<Button label="Novo aviso" icon="pi pi-plus" @click="openCreate" />
</div>
</div>
<!-- Lista -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<div v-else-if="!notices.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-megaphone text-4xl opacity-30 block mb-3" />
Nenhum aviso cadastrado.
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="notice in notices"
:key="notice.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex gap-4 items-start"
:class="{ 'opacity-55': !notice.is_active }"
>
<!-- Variante pill -->
<div class="shrink-0 pt-0.5">
<Tag
:value="VARIANT_LABELS[notice.variant] || notice.variant"
:severity="VARIANT_SEVERITY[notice.variant]"
class="text-[0.7rem]"
/>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span v-if="notice.title" class="font-semibold text-sm">{{ notice.title }}</span>
<span class="text-xs text-[var(--text-color-secondary)] truncate max-w-[320px]"
v-html="notice.message" />
</div>
<div class="flex gap-3 mt-2 flex-wrap text-xs text-[var(--text-color-secondary)]">
<span><b>Prioridade:</b> {{ notice.priority }}</span>
<span><b>Versão:</b> {{ notice.version }}</span>
<span v-if="notice.starts_at"><b>De:</b> {{ fmtDate(notice.starts_at) }}</span>
<span v-if="notice.ends_at"><b>Até:</b> {{ fmtDate(notice.ends_at) }}</span>
<span v-if="notice.roles?.length"><b>Roles:</b> {{ notice.roles.join(', ') }}</span>
<span v-if="notice.contexts?.length"><b>Contextos:</b> {{ notice.contexts.join(', ') }}</span>
<span class="text-[var(--text-color-secondary)] opacity-70">
👁 {{ notice.views_count }} visualizações · 🖱 {{ notice.clicks_count }} cliques
</span>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 shrink-0">
<Button
:icon="notice.is_active ? 'pi pi-eye-slash' : 'pi pi-eye'"
text rounded size="small"
:severity="notice.is_active ? 'secondary' : 'success'"
:title="notice.is_active ? 'Desativar' : 'Ativar'"
@click="toggle(notice)"
/>
<Button
icon="pi pi-pencil"
text rounded size="small"
title="Editar"
@click="openEdit(notice)"
/>
<Button
icon="pi pi-trash"
text rounded size="small"
severity="danger"
title="Excluir"
@click="confirmDelete(notice)"
/>
</div>
</div>
</div>
<!-- Dialog Criar/Editar -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Novo Aviso' : 'Editar Aviso'"
modal
:style="{ width: '680px', maxWidth: '95vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Variante + Título -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Variante *</label>
<Select
v-model="form.variant"
:options="VARIANT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Título (opcional)</label>
<InputText v-model="form.title" class="w-full" placeholder="Ex: Manutenção programada" />
</div>
</div>
<!-- Mensagem + Alinhamento -->
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2 flex-wrap">
<label class="text-xs font-semibold">Mensagem * <span class="font-normal opacity-60">(aceita HTML simples)</span></label>
<SelectButton
v-model="form.content_align"
:options="ALIGN_OPTIONS"
option-value="value"
:allow-empty="false"
size="small"
>
<template #option="{ option }">
<i :class="option.icon" class="text-[0.75rem]" :title="option.label" />
</template>
</SelectButton>
</div>
<Textarea
v-model="form.message"
rows="3"
class="w-full"
placeholder='Ex: O sistema ficará indisponível em <b>15/04 às 02h</b>.'
auto-resize
/>
</div>
<!-- Segmentação -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Roles <span class="font-normal opacity-60">(vazio = todos)</span></label>
<MultiSelect
v-model="form.roles"
:options="ROLE_OPTIONS"
option-label="label"
option-value="value"
placeholder="Todos os roles"
class="w-full"
display="chip"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Contextos <span class="font-normal opacity-60">(vazio = todos)</span></label>
<MultiSelect
v-model="form.contexts"
:options="CONTEXT_OPTIONS"
option-label="label"
option-value="value"
placeholder="Todos os contextos"
class="w-full"
display="chip"
/>
</div>
</div>
<!-- Tempo -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Exibir a partir de</label>
<InputText v-model="form.starts_at" type="datetime-local" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Exibir até</label>
<InputText v-model="form.ends_at" type="datetime-local" class="w-full" />
</div>
</div>
<!-- Prioridade + Versão + Ativo -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Prioridade</label>
<InputNumber v-model="form.priority" :min="0" :max="999" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Versão</label>
<InputNumber v-model="form.version" :min="1" class="w-full" />
</div>
<div class="flex flex-col gap-1.5 justify-end">
<div class="flex items-center gap-2 h-[42px]">
<ToggleSwitch v-model="form.is_active" inputId="sw-active" />
<label for="sw-active" class="text-sm cursor-pointer">Ativo</label>
</div>
</div>
</div>
<!-- Dismiss -->
<div class="border border-[var(--surface-border)] rounded-lg p-3 flex flex-col gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Dismiss</span>
<div class="grid grid-cols-3 gap-3">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.dismissible" inputId="sw-dismissible" />
<label for="sw-dismissible" class="text-sm cursor-pointer">Fechável</label>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.persist_dismiss" inputId="sw-persist" :disabled="!form.dismissible" />
<label for="sw-persist" class="text-sm cursor-pointer">Persistir fechamento</label>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Escopo</label>
<Select
v-model="form.dismiss_scope"
:options="SCOPE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
:disabled="!form.dismissible || !form.persist_dismiss"
/>
</div>
</div>
</div>
<!-- Regras de exibição -->
<div class="border border-[var(--surface-border)] rounded-lg p-3 flex flex-col gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Regras de exibição</span>
<div class="grid grid-cols-3 gap-3">
<div class="flex items-center gap-2 col-span-1">
<ToggleSwitch v-model="form.show_once" inputId="sw-once" />
<label for="sw-once" class="text-sm cursor-pointer">Exibir 1x</label>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Máx. visualizações</label>
<InputNumber v-model="form.max_views" :min="1" placeholder="Ilimitado" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Cooldown (minutos)</label>
<InputNumber v-model="form.cooldown_minutes" :min="1" placeholder="Sem cooldown" class="w-full" />
</div>
</div>
</div>
<!-- CTA -->
<div class="border border-[var(--surface-border)] rounded-lg p-3 flex flex-col gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Ação (CTA)</span>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Tipo de ação</label>
<SelectButton
v-model="form.action_type"
:options="ACTION_OPTIONS"
option-label="label"
option-value="value"
/>
</div>
<!-- CTA externa -->
<div v-if="showCta && form.action_type === 'external'" class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Label do botão</label>
<InputText v-model="form.action_label" class="w-full" placeholder="Ex: Saiba mais" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">URL externa</label>
<InputText v-model="form.action_url" class="w-full" placeholder="https://..." />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Target</label>
<Select
v-model="form.link_target"
:options="TARGET_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
</div>
<!-- CTA interna -->
<div v-if="showCta && form.action_type === 'internal'" class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Label do botão</label>
<InputText v-model="form.action_label" class="w-full" placeholder="Ex: Ver planos" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Rota interna</label>
<Select
v-model="form.action_route"
:options="routeOptions"
option-label="label"
option-value="value"
class="w-full"
filter
filterPlaceholder="Buscar rota..."
placeholder="Selecione uma rota"
:virtualScrollerOptions="{ itemSize: 38 }"
/>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button
:label="dlg.mode === 'create' ? 'Criar aviso' : 'Salvar'"
icon="pi pi-check"
:loading="dlg.saving"
@click="save"
/>
</template>
</Dialog>
<!-- Dialog de Ajuda -->
<Dialog
v-model:visible="showHelp"
header="Como funciona o Aviso Global"
modal
:style="{ width: '680px', maxWidth: '95vw' }"
:draggable="false"
>
<Accordion :value="['variant']" multiple>
<AccordionPanel value="variant">
<AccordionHeader>
<i class="pi pi-palette mr-2 text-[var(--primary-color)]" />
Variante
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Define a cor de fundo do banner: <b>Info</b> (azul), <b>Sucesso</b> (verde), <b>Atenção</b> (âmbar) ou <b>Erro</b> (vermelho).
O ícone lateral muda automaticamente conforme a variante escolhida.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="segmentation">
<AccordionHeader>
<i class="pi pi-users mr-2 text-[var(--primary-color)]" />
Segmentação (Roles e Contextos)
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Roles</b> filtram por perfil do usuário (ex: <code>clinic_admin</code>, <code>therapist</code>). Deixar vazio exibe para todos os perfis.<br/><br/>
<b>Contextos</b> filtram pela área da aplicação em que o usuário está (ex: <code>clinic</code> = <code>/admin</code>, <code>therapist</code> = <code>/therapist</code>). Deixar vazio exibe em qualquer área.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="time">
<AccordionHeader>
<i class="pi pi-calendar mr-2 text-[var(--primary-color)]" />
Controle de tempo
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Exibir a partir de / Exibir até</b>: janela de tempo em que o aviso pode aparecer. Deixar em branco = sem restrição de data.<br/><br/>
O campo <b>Ativo</b> desativa manualmente o aviso independentemente do período configurado.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="priority">
<AccordionHeader>
<i class="pi pi-sort-amount-up mr-2 text-[var(--primary-color)]" />
Prioridade
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Quando mais de um aviso é elegível para o mesmo usuário, apenas o de <b>maior prioridade</b> aparece.
Use números inteiros ex: 10, 50, 100. O padrão é 0 (menor prioridade).
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="dismiss">
<AccordionHeader>
<i class="pi pi-times-circle mr-2 text-[var(--primary-color)]" />
Dismiss (fechamento)
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Fechável</b>: exibe o botão × no banner. Se desativado, o usuário não pode fechar manualmente.<br/><br/>
<b>Persistir fechamento</b>: lembra que o usuário fechou para não reexibir.<br/><br/>
<b>Escopo</b> do dismiss:<br/>
&bull; <b>Dispositivo</b> salvo em <code>localStorage</code> (persiste entre sessões no mesmo navegador).<br/>
&bull; <b>Sessão</b> salvo em <code>sessionStorage</code> (apaga ao fechar o navegador).<br/>
&bull; <b>Usuário</b> salvo no banco de dados (persiste em qualquer dispositivo do mesmo usuário).
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="rules">
<AccordionHeader>
<i class="pi pi-eye mr-2 text-[var(--primary-color)]" />
Regras de exibição
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Exibir 1x</b>: após a primeira visualização, o aviso não aparece mais para aquele usuário/dispositivo.<br/><br/>
<b>Máx. visualizações</b>: limita quantas vezes o aviso pode aparecer. Ex: 3 = aparece no máximo 3 vezes.<br/><br/>
<b>Cooldown (minutos)</b>: intervalo mínimo entre exibições. Ex: 60 = reexibe após 1 hora desde a última vez que apareceu.
Útil para avisos recorrentes que não devem incomodar em cada pageload.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="version">
<AccordionHeader>
<i class="pi pi-history mr-2 text-[var(--primary-color)]" />
Versão
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Ao incrementar a <b>Versão</b> de um aviso publicado, todos os dismissals anteriores ficam inválidos
o aviso volta a aparecer para usuários que tinham fechado.
Use ao editar o conteúdo de um aviso importante que precisa ser relido.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="cta">
<AccordionHeader>
<i class="pi pi-arrow-right mr-2 text-[var(--primary-color)]" />
Ação (CTA)
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
<b>Nenhuma</b>: banner informativo, sem botão de ação.<br/><br/>
<b>Interna</b>: botão que navega para uma rota da própria aplicação (sem abrir nova aba).
Selecione a rota no campo as opções são geradas automaticamente pelo router.<br/><br/>
<b>Externa</b>: botão que abre uma URL externa. Defina o <b>Target</b> (<code>_blank</code> = nova aba, padrão).
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="tracking">
<AccordionHeader>
<i class="pi pi-chart-bar mr-2 text-[var(--primary-color)]" />
Rastreamento
</AccordionHeader>
<AccordionContent>
<p class="text-sm leading-relaxed m-0">
Cada aviso acumula <b>visualizações</b> e <b>cliques no CTA</b> de forma agregada (sem identificar o usuário).
Os contadores são exibidos na listagem e incrementados via RPC no banco de dados.
</p>
</AccordionContent>
</AccordionPanel>
</Accordion>
<template #footer>
<Button label="Fechar" @click="showHelp = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>