Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
File diff suppressed because it is too large Load Diff
+550
View File
@@ -0,0 +1,550 @@
<!-- src/views/pages/saas/SaasFaqPage.vue -->
<!-- Portal de FAQ consulta de perguntas frequentes por usuários logados -->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useDocsAdmin } from '@/composables/useDocsAdmin'
const router = useRouter()
const { requestEditDoc } = useDocsAdmin()
function editarDoc (docId) {
requestEditDoc(docId)
router.push('/saas/docs')
}
// ── Estado ────────────────────────────────────────────────────
const loading = ref(false)
const docs = ref([]) // docs com exibir_no_faq = true
const faqItens = ref([]) // todos os itens FAQ dos docs acima
const busca = ref('')
const catAtiva = ref(null) // categoria selecionada no sidebar
// Controla quais perguntas estão abertas { [itemId]: boolean }
const abertos = ref({})
// ── Load ──────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
// Busca docs habilitados no FAQ
const { data: docsData, error: docsErr } = await supabase
.from('saas_docs')
.select('id, titulo, categoria, ordem, pagina_path')
.eq('ativo', true)
.eq('exibir_no_faq', true)
.order('categoria')
.order('ordem')
if (docsErr) throw docsErr
docs.value = docsData || []
if (!docs.value.length) return
// Busca todos os itens FAQ desses docs
const docIds = docs.value.map(d => d.id)
const { data: itensData, error: itensErr } = await supabase
.from('saas_faq_itens')
.select('id, doc_id, pergunta, resposta, ordem')
.in('doc_id', docIds)
.eq('ativo', true)
.order('ordem')
if (itensErr) throw itensErr
faqItens.value = itensData || []
} finally {
loading.value = false
}
}
onMounted(load)
// ── Categorias disponíveis ────────────────────────────────────
const categorias = computed(() => {
const set = new Set(docs.value.map(d => d.categoria).filter(Boolean))
return [...set].sort()
})
// ── Docs filtrados pela categoria ativa ───────────────────────
const docsFiltrados = computed(() => {
if (!catAtiva.value) return docs.value
return docs.value.filter(d => d.categoria === catAtiva.value)
})
// ── Itens de um doc, aplicando busca ─────────────────────────
function itensDo (docId) {
const q = busca.value.trim().toLowerCase()
return faqItens.value.filter(f => {
if (f.doc_id !== docId) return false
if (!q) return true
return (
f.pergunta.toLowerCase().includes(q) ||
(f.resposta || '').toLowerCase().includes(q)
)
})
}
// ── Docs que têm resultado na busca ──────────────────────────
const docsComResultado = computed(() => {
return docsFiltrados.value.filter(d => itensDo(d.id).length > 0)
})
// Total de resultados para feedback
const totalResultados = computed(() => {
if (!busca.value.trim()) return null
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0)
})
// ── Toggle pergunta ───────────────────────────────────────────
function toggle (id) {
abertos.value[id] = !abertos.value[id]
}
// Abre todas as perguntas dos resultados quando há busca ativa
function expandirResultados () {
docsComResultado.value.forEach(d => {
itensDo(d.id).forEach(item => {
abertos.value[item.id] = true
})
})
}
// Observa busca: expande automaticamente quando tem busca
watch(busca, (val) => {
if (val.trim()) expandirResultados()
})
// ── Selecionar categoria ──────────────────────────────────────
function selecionarCat (cat) {
catAtiva.value = catAtiva.value === cat ? null : cat
busca.value = ''
abertos.value = {}
}
</script>
<template>
<div class="faq-page">
<!-- Cabeçalho -->
<div class="faq-header">
<div class="faq-header-inner">
<div class="flex items-center gap-3 mb-3">
<div class="faq-icon-wrap">
<i class="pi pi-comments text-xl" />
</div>
<div>
<h1 class="faq-title">Central de Ajuda</h1>
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
</div>
</div>
<!-- Busca -->
<div class="faq-search-wrap">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="faq-search-input"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="faq-search-result">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
<!-- Corpo -->
<div class="faq-body">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="faq-sidebar">
<div class="faq-sidebar-title">Categorias</div>
<button
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large text-xs mr-2" />
Todas
<span class="faq-cat-count">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag text-xs mr-2 opacity-60" />
{{ cat }}
<span class="faq-cat-count">
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<main class="faq-main">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="faq-empty">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
Limpar filtros
</button>
</div>
<!-- Grupos de docs -->
<div
v-for="doc in docsComResultado"
:key="doc.id"
class="faq-group"
>
<!-- Cabeçalho do grupo (doc) -->
<div class="faq-group-header">
<div class="faq-group-icon">
<i class="pi pi-file-edit text-sm" />
</div>
<div class="flex-1 min-w-0">
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
</div>
<button
class="edit-doc-btn"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil text-xs" />
</button>
</div>
<!-- Itens FAQ do grupo -->
<div class="faq-items">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="faq-item"
:class="{ 'faq-item--open': abertos[item.id] }"
>
<button class="faq-pergunta" @click="toggle(item.id)">
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
<i
class="pi shrink-0 text-sm opacity-40 transition-transform duration-200"
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<Transition name="faq-expand">
<div
v-if="abertos[item.id] && item.resposta"
class="faq-resposta ql-content"
v-html="item.resposta"
/>
</Transition>
</div>
</div>
</div>
</main>
</template>
</div>
</div>
</template>
<style scoped>
/* ── Layout ──────────────────────────────────────────────────── */
.faq-page {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* ── Header ─────────────────────────────────────────────────── */
.faq-header {
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
padding: 2rem 1.5rem 1.5rem;
}
.faq-header-inner {
max-width: 720px;
margin: 0 auto;
}
.faq-icon-wrap {
width: 48px;
height: 48px;
border-radius: 14px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-color);
margin: 0;
line-height: 1.2;
}
.faq-subtitle {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 2px 0 0;
}
.faq-search-wrap {
position: relative;
}
.faq-search-input {
width: 100%;
border-radius: 0.75rem !important;
font-size: 0.9rem;
}
.faq-search-result {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin-top: 0.375rem;
margin-left: 0.25rem;
}
/* ── Corpo ──────────────────────────────────────────────────── */
.faq-body {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
flex: 1;
max-width: 1100px;
margin: 0 auto;
width: 100%;
align-items: flex-start;
}
/* ── Sidebar ─────────────────────────────────────────────────── */
.faq-sidebar {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.faq-sidebar-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-color-secondary);
opacity: 0.6;
padding: 0 0.5rem;
margin-bottom: 0.25rem;
}
.faq-cat-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.82rem;
color: var(--text-color-secondary);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.faq-cat-btn:hover {
background: var(--surface-hover);
color: var(--text-color);
}
.faq-cat-btn--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.faq-cat-count {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.5;
font-weight: 500;
}
/* ── Main ─────────────────────────────────────────────────────── */
.faq-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.faq-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
text-align: center;
}
/* ── Grupo (doc) ─────────────────────────────────────────────── */
.faq-group {
border: 1px solid var(--surface-border);
border-radius: 1rem;
overflow: hidden;
background: var(--surface-card);
}
.faq-group-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.faq-group-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-group-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
line-height: 1.3;
}
.faq-group-cat {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.6;
display: block;
margin-top: 1px;
}
.edit-doc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.faq-group-header:hover .edit-doc-btn {
opacity: 1;
}
.edit-doc-btn:hover {
background: var(--surface-hover);
color: var(--primary-color);
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* ── Itens FAQ ───────────────────────────────────────────────── */
.faq-items {
display: flex;
flex-direction: column;
}
.faq-item {
border-bottom: 1px solid var(--surface-border);
transition: background 0.15s;
}
.faq-item:last-child { border-bottom: none; }
.faq-item--open { background: color-mix(in srgb, var(--primary-color) 3%, transparent); }
.faq-pergunta {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.faq-pergunta:hover { background: var(--surface-hover); }
.faq-pergunta-text {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color);
line-height: 1.4;
}
.faq-resposta {
padding: 0 1.25rem 1rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
line-height: 1.65;
word-break: break-word;
}
/* Quill content */
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.faq-resposta.ql-content :deep(em) { font-style: italic; }
.faq-resposta.ql-content :deep(ul),
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.faq-resposta.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
font-style: italic;
}
/* Animação expand */
.faq-expand-enter-active,
.faq-expand-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease;
max-height: 800px;
overflow: hidden;
}
.faq-expand-enter-from,
.faq-expand-leave-to {
opacity: 0;
max-height: 0;
}
/* ── Responsivo ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.faq-body { flex-direction: column; padding: 1rem; }
.faq-sidebar { width: 100%; position: static; flex-direction: row; flex-wrap: wrap; gap: 0.375rem; }
.faq-sidebar-title { display: none; }
.faq-cat-btn { width: auto; padding: 0.3rem 0.625rem; font-size: 0.75rem; }
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<!-- src/views/pages/saas/SaasFeriadosPage.vue -->
<!-- SAAS admin: visualização centralizada de feriados municipais cadastrados pelos tenants -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import DatePicker from 'primevue/datepicker'
const toast = useToast()
// ── Estado ───────────────────────────────────────────────────
const loading = ref(false)
const feriados = ref([])
const tenants = ref([])
const ano = ref(new Date().getFullYear())
const search = ref('')
// ── Filtros ──────────────────────────────────────────────────
const filtroEstado = ref(null)
const filtroCidade = ref(null)
// ── Dialog ───────────────────────────────────────────────────
const dlgOpen = ref(false)
const saving = ref(false)
const form = ref(emptyForm())
function emptyForm () {
return {
nome: '',
data: null,
cidade: '',
estado: '',
tenant_id: null,
observacao: '',
bloqueia_sessoes: false
}
}
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
function abrirDialog () {
form.value = emptyForm()
dlgOpen.value = true
}
function dateToISO (d) {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
}
async function salvar () {
if (!formValid.value) return
saving.value = true
try {
const { data: me } = await supabase.auth.getUser()
const payload = {
owner_id: me?.user?.id || null,
tenant_id: form.value.tenant_id || null,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: dateToISO(form.value.data),
cidade: form.value.cidade.trim() || null,
estado: form.value.estado.trim() || null,
observacao: form.value.observacao.trim() || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
}
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 })
dlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
saving.value = false
}
}
// ── Load feriados ─────────────────────────────────────────────
async function load () {
loading.value = true
try {
const { data, error } = await supabase
.from('feriados')
.select('*, tenants(name)')
.gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`)
.order('data')
if (error) throw error
feriados.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
loading.value = false
}
}
// ── Load tenants (para o select do dialog) ────────────────────
async function loadTenants () {
const { data } = await supabase.from('tenants').select('id, name').order('name')
tenants.value = data || []
}
onMounted(() => { load(); loadTenants() })
// ── Navegação de ano ─────────────────────────────────────────
async function anoAnterior () { ano.value--; await load() }
async function anoProximo () { ano.value++; await load() }
// ── Helpers ──────────────────────────────────────────────────
function fmtDate (iso) {
if (!iso) return '—'
const [y, m, d] = String(iso).split('-')
return `${d}/${m}/${y}`
}
// ── Opções de filtro ─────────────────────────────────────────
const estadoOptions = computed(() => {
const set = new Set(feriados.value.map(f => f.estado).filter(Boolean))
return [{ label: 'Todos os estados', value: null }, ...[...set].sort().map(e => ({ label: e, value: e }))]
})
const cidadeOptions = computed(() => {
const set = new Set(
feriados.value
.filter(f => !filtroEstado.value || f.estado === filtroEstado.value)
.map(f => f.cidade)
.filter(Boolean)
)
return [{ label: 'Todas as cidades', value: null }, ...[...set].sort().map(c => ({ label: c, value: c }))]
})
const tenantOptions = computed(() => [
{ label: 'Sem vínculo (global)', value: null },
...tenants.value.map(t => ({ label: t.name, value: t.id }))
])
// ── Lista filtrada ────────────────────────────────────────────
const listaFiltrada = computed(() => {
let list = feriados.value
if (filtroEstado.value) list = list.filter(f => f.estado === filtroEstado.value)
if (filtroCidade.value) list = list.filter(f => f.cidade === filtroCidade.value)
const q = search.value.trim().toLowerCase()
if (q) list = list.filter(f => f.nome.toLowerCase().includes(q) || (f.cidade || '').toLowerCase().includes(q))
return list
})
// ── Agrupamento por data ──────────────────────────────────────
const agrupados = computed(() => {
const map = new Map()
for (const f of listaFiltrada.value) {
if (!map.has(f.data)) map.set(f.data, [])
map.get(f.data).push(f)
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
})
// ── Stats ─────────────────────────────────────────────────────
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)
// ── Excluir ───────────────────────────────────────────────────
async function excluir (id) {
try {
const { error } = await supabase.from('feriados').delete().eq('id', id)
if (error) throw error
feriados.value = feriados.value.filter(f => f.id !== id)
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
</script>
<template>
<Toast />
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<div>
<div class="font-bold text-lg flex items-center gap-2">
<i class="pi pi-star text-amber-500" />
Feriados Municipais
</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
Feriados cadastrados pelos tenants alimentam o banco central de feriados do SAAS.
</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Cadastrar feriado" class="rounded-full" @click="abrirDialog" />
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-amber-500">{{ totalFeriados }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalTenants }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-green-500">{{ totalMunicipios }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Municípios</div>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex-1 min-w-[160px]">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" class="w-full" placeholder="Buscar feriado ou cidade…" />
</IconField>
</div>
<Select
v-model="filtroEstado"
:options="estadoOptions"
optionLabel="label"
optionValue="value"
class="min-w-[160px]"
@change="filtroCidade = null"
/>
<Select
v-model="filtroCidade"
:options="cidadeOptions"
optionLabel="label"
optionValue="value"
class="min-w-[180px]"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
</div>
<template v-else>
<div v-if="!agrupados.length" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<!-- Lista agrupada por data -->
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
<div class="blk-group__head">
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
<span class="blk-group__count">{{ lista.length }}</span>
</div>
<div class="blk-list">
<div v-for="f in lista" :key="f.id" class="blk-item">
<div class="blk-item__name">{{ f.nome }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" class="text-xs" />
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
</div>
<div v-if="f.tenants?.name" class="blk-item__tenant">
<i class="pi pi-building text-xs" /> {{ f.tenants.name }}
</div>
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
<div class="blk-item__actions">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Dialog cadastro -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
header="Cadastrar feriado"
:style="{ width: '460px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="dlg-label">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
</div>
<div>
<label class="dlg-label">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="dlg-label">Cidade</label>
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
</div>
<div class="w-24">
<label class="dlg-label">Estado (UF)</label>
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
</div>
</div>
<div>
<label class="dlg-label">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<Select
v-model="form.tenant_id"
:options="tenantOptions"
optionLabel="label"
optionValue="value"
class="w-full mt-1"
placeholder="Sem vínculo (global)"
/>
</div>
<div>
<label class="dlg-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
<label for="bloqueia" class="text-sm cursor-pointer">Bloqueia sessões neste dia</label>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:disabled="!formValid"
:loading="saving"
@click="salvar"
/>
</template>
</Dialog>
</template>
<style scoped>
.blk-group {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
background: var(--surface-ground);
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
color: var(--text-color-secondary);
}
.blk-list { display: flex; flex-direction: column; }
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__name {
font-weight: 500;
font-size: 0.875rem;
flex: 1;
min-width: 180px;
}
.blk-item__tenant {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
display: flex;
align-items: center;
gap: 0.25rem;
}
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
font-style: italic;
}
.blk-item__actions { margin-left: auto; }
.dlg-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>
+364
View File
@@ -0,0 +1,364 @@
<template>
<div class="saas-support p-4 md:p-6">
<Toast />
<!-- Cabeçalho -->
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
</div>
<div>
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0 m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere links seguros para acessar a agenda de um cliente em modo debug</p>
</div>
</div>
<!-- Card: Gerar nova sessão -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card">
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
<i class="pi pi-plus-circle text-primary" />
Nova Sessão de Suporte
</h2>
<div class="flex flex-col gap-4">
<!-- Seleção de tenant -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
option-label="label"
option-value="value"
placeholder="Buscar tenant..."
filter
:loading="loadingTenants"
class="w-full"
empty-filter-message="Nenhum tenant encontrado"
/>
</div>
<!-- TTL -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<!-- Botão -->
<Button
label="Ativar Modo Suporte"
icon="pi pi-shield"
severity="warning"
:loading="creating"
:disabled="!selectedTenantId"
class="w-full"
@click="handleCreate"
/>
</div>
</div>
<!-- Card: URL Gerada -->
<div class="card">
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
<i class="pi pi-link text-primary" />
URL de Suporte Gerada
</h2>
<div v-if="generatedUrl" class="flex flex-col gap-3">
<!-- URL -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Link de Acesso</label>
<div class="flex gap-2">
<InputText
:value="generatedUrl"
readonly
class="flex-1 font-mono text-xs"
/>
<Button
icon="pi pi-copy"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copyUrl"
/>
</div>
</div>
<!-- Expira em -->
<div class="flex items-center gap-2 text-sm text-surface-500">
<i class="pi pi-clock text-orange-500" />
<span>Expira em: <strong class="text-surface-700 dark:text-surface-300">{{ expiresLabel }}</strong></span>
</div>
<!-- Token (reduzido) -->
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<!-- Instruções -->
<Message severity="info" :closable="false" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para ver os logs da agenda.
O link expira automaticamente.
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-10 text-surface-400 gap-2">
<i class="pi pi-shield text-4xl opacity-30" />
<span class="text-sm">Nenhuma sessão gerada ainda</span>
</div>
</div>
</div>
<!-- Sessões ativas -->
<div class="card mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-list text-primary" />
Sessões Ativas
</h2>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loadingSessions"
@click="loadActiveSessions"
/>
</div>
<DataTable
:value="activeSessions"
:loading="loadingSessions"
empty-message="Nenhuma sessão ativa no momento"
size="small"
striped-rows
>
<Column field="tenant_id" header="Tenant ID">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.tenant_id }}</span>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.token.slice(0, 16) }}</span>
</template>
</Column>
<Column header="Expira em">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ formatExpires(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="Ações">
<template #body="{ data }">
<div class="flex gap-2">
<Button
icon="pi pi-copy"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copySessionUrl(data.token)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Revogar'"
:loading="revokingToken === data.token"
@click="handleRevoke(data.token)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSupportSession,
listActiveSupportSessions,
revokeSupportSession,
buildSupportUrl,
} from '@/support/supportSessionService'
const toast = useToast()
// ── Estado ─────────────────────────────────────────────────────────────────
const selectedTenantId = ref(null)
const ttlMinutes = ref(60)
const creating = ref(false)
const loadingTenants = ref(false)
const loadingSessions = ref(false)
const revokingToken = ref(null)
const tenants = ref([])
const activeSessions = ref([])
const generatedUrl = ref(null)
const generatedData = ref(null) // { token, expires_at }
// ── Opções de TTL ──────────────────────────────────────────────────────────
const ttlOptions = [
{ label: '30 minutos', value: 30 },
{ label: '60 minutos', value: 60 },
{ label: '2 horas', value: 120 },
]
// ── Computed ───────────────────────────────────────────────────────────────
const expiresLabel = computed(() => {
if (!generatedData.value?.expires_at) return ''
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
})
const tokenPreview = computed(() => {
if (!generatedData.value?.token) return ''
const t = generatedData.value.token
return `${t.slice(0, 8)}${t.slice(-8)}`
})
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMounted(() => {
loadTenants()
loadActiveSessions()
})
// ── Métodos ────────────────────────────────────────────────────────────────
async function loadTenants () {
loadingTenants.value = true
try {
const { data, error } = await supabase
.from('tenants')
.select('id, name, kind')
.order('name', { ascending: true })
if (error) throw error
tenants.value = (data || []).map(t => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`,
}))
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingTenants.value = false
}
}
async function loadActiveSessions () {
loadingSessions.value = true
try {
activeSessions.value = await listActiveSupportSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingSessions.value = false
}
}
async function handleCreate () {
if (!selectedTenantId.value) return
creating.value = true
generatedUrl.value = null
generatedData.value = null
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value)
generatedData.value = result
generatedUrl.value = buildSupportUrl(result.token)
toast.add({
severity: 'success',
summary: 'Sessão criada',
detail: 'URL de suporte gerada com sucesso.',
life: 4000,
})
await loadActiveSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
} finally {
creating.value = false
}
}
async function handleRevoke (token) {
revokingToken.value = token
try {
await revokeSupportSession(token)
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
if (generatedData.value?.token === token) {
generatedUrl.value = null
generatedData.value = null
}
await loadActiveSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
} finally {
revokingToken.value = null
}
}
function copyUrl () {
if (!generatedUrl.value) return
navigator.clipboard.writeText(generatedUrl.value)
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
}
function copySessionUrl (token) {
const url = buildSupportUrl(token)
navigator.clipboard.writeText(url)
toast.add({ severity: 'info', summary: 'Copiado!', life: 2000 })
}
// ── Formatação ─────────────────────────────────────────────────────────────
function formatDate (iso) {
if (!iso) return '-'
return new Date(iso).toLocaleString('pt-BR')
}
function formatExpires (iso) {
if (!iso) return '-'
const d = new Date(iso)
const now = new Date()
const diffMin = Math.round((d - now) / 60000)
if (diffMin < 0) return 'Expirada'
if (diffMin < 60) return `em ${diffMin} min`
return new Date(iso).toLocaleString('pt-BR')
}
function isExpiringSoon (iso) {
if (!iso) return false
const diffMin = (new Date(iso) - new Date()) / 60000
return diffMin > 0 && diffMin < 15
}
</script>