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