Agenda, Agendador, Configurações
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user