Files
agenciapsilmno/src/composables/useAjuda.js

368 lines
12 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAjuda.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// - Home: docs da sessão atual + outros docs paginados + FAQ
// - Navegação interna com stack (voltar)
// - Votação por usuário (útil / não útil)
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
const ADMIN_ROLES = ['clinic_admin', 'tenant_admin']
function isCurrentUserAdmin () {
return sessionIsSaasAdmin.value || ADMIN_ROLES.includes(sessionRole.value)
}
// ── Singleton state ────────────────────────────────────────────
const cache = new Map() // path → { docs, relatedDocs, faqItens }
const docCache = new Map() // id → { docs, relatedDocs, faqItens }
// Estado da sessão atual
const sessionDocs = ref([])
const sessionFaq = ref([])
const sessionPath = ref('')
// Estado da home (todos os docs paginados + FAQ global)
const allDocs = ref([])
const allDocsTotal = ref(0)
const allDocsPage = ref(0)
const allDocsLoading = ref(false)
const ALL_DOCS_PAGE_SIZE = 8
const globalFaq = ref([])
const globalFaqLoading = ref(false)
// Drawer
const drawerOpen = ref(false)
const loading = ref(false)
// Stack de navegação — cada entrada: { currentDoc, label }
const navStack = ref([])
// null = home, objeto = doc aberto
const currentDoc = ref(null)
const isHome = computed(() => currentDoc.value === null)
const isNavigating = computed(() => navStack.value.length > 0)
// Votos do usuário { [docId]: true | false | null }
const meusVotos = ref({})
// ── Helpers ───────────────────────────────────────────────────
function normalizePath (path) {
return String(path || '').split('?')[0].split('#')[0].replace(/\/$/, '') || '/'
}
async function fetchFaqItensForDocs (docIds) {
if (!docIds.length) return []
const { data } = await supabase
.from('saas_faq_itens')
.select('id, doc_id, pergunta, resposta, ordem')
.in('doc_id', docIds)
.eq('ativo', true)
.order('doc_id')
.order('ordem')
return data || []
}
// ── Fetch por rota (chamado pelo AppLayout ao mudar de rota) ──
export async function fetchDocsForPath (rawPath) {
const path = normalizePath(rawPath)
sessionPath.value = path
currentDoc.value = null
navStack.value = []
// Reseta paginação de outros docs ao mudar de rota
allDocs.value = []
allDocsPage.value = 0
if (cache.has(path)) {
const cached = cache.get(path)
sessionDocs.value = cached.docs
sessionFaq.value = cached.faqItens
return
}
loading.value = true
try {
const { data, error } = await supabase
.from('saas_docs')
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
.eq('pagina_path', path)
.eq('ativo', true)
.order('ordem')
if (error) throw error
const userIsAdmin = isCurrentUserAdmin()
const mainDocs = (data || []).filter(d =>
d.tipo_acesso === 'usuario' || userIsAdmin
)
const allIds = [...new Set(mainDocs.flatMap(d => d.docs_relacionados || []))]
let related = []
if (allIds.length) {
const { data: relData } = await supabase
.from('saas_docs')
.select('id, titulo, pagina_path')
.in('id', allIds)
.eq('ativo', true)
related = relData || []
}
const itens = await fetchFaqItensForDocs(mainDocs.map(d => d.id))
cache.set(path, { docs: mainDocs, relatedDocs: related, faqItens: itens })
sessionDocs.value = mainDocs
sessionFaq.value = itens
} catch (e) {
console.warn('[useAjuda] erro ao buscar docs:', e?.message || e)
sessionDocs.value = []
sessionFaq.value = []
} finally {
loading.value = false
}
}
// ── Todos os docs paginados (para a home do drawer) ───────────
export async function loadAllDocs (page = 0) {
allDocsLoading.value = true
try {
const from = page * ALL_DOCS_PAGE_SIZE
const to = from + ALL_DOCS_PAGE_SIZE - 1
const userIsAdmin = isCurrentUserAdmin()
let query = supabase
.from('saas_docs')
.select('id, titulo, pagina_path, categoria, votos_util, votos_nao_util', { count: 'exact' })
.eq('ativo', true)
.order('titulo')
.range(from, to)
if (!userIsAdmin) query = query.eq('tipo_acesso', 'usuario')
const { data, count, error } = await query
if (error) throw error
// Exclui docs da sessão atual
const sessionIds = new Set(sessionDocs.value.map(d => d.id))
const filtered = (data || []).filter(d => !sessionIds.has(d.id))
allDocs.value = page === 0 ? filtered : [...allDocs.value, ...filtered]
allDocsTotal.value = count || 0
allDocsPage.value = page
} catch (e) {
console.warn('[useAjuda] erro ao carregar todos os docs:', e?.message || e)
} finally {
allDocsLoading.value = false
}
}
// ── FAQ global (itens fora da sessão atual) ───────────────────
export async function loadGlobalFaq () {
globalFaqLoading.value = true
try {
const userIsAdmin = isCurrentUserAdmin()
let q = supabase
.from('saas_docs')
.select('id, titulo')
.eq('ativo', true)
.eq('exibir_no_faq', true)
if (!userIsAdmin) q = q.eq('tipo_acesso', 'usuario')
const { data: faqDocs } = await q
if (!faqDocs?.length) { globalFaq.value = []; return }
const sessionIds = new Set(sessionDocs.value.map(d => d.id))
const outrosIds = faqDocs.filter(d => !sessionIds.has(d.id)).map(d => d.id)
if (!outrosIds.length) { globalFaq.value = []; return }
const itens = await fetchFaqItensForDocs(outrosIds)
globalFaq.value = itens.map(item => ({
...item,
_docTitulo: faqDocs.find(d => d.id === item.doc_id)?.titulo || ''
}))
} catch (e) {
console.warn('[useAjuda] erro ao carregar FAQ global:', e?.message || e)
globalFaq.value = []
} finally {
globalFaqLoading.value = false
}
}
// ── Votos do usuário ──────────────────────────────────────────
export async function loadMeusVotos () {
try {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase
.from('saas_doc_votos')
.select('doc_id, util')
.eq('user_id', user.id)
meusVotos.value = {}
for (const v of (data || [])) meusVotos.value[v.doc_id] = v.util
} catch (e) {
console.warn('[useAjuda] erro ao carregar votos:', e?.message || e)
}
}
export async function votar (docId, util) {
try {
const { data, error } = await supabase.rpc('saas_votar_doc', {
p_doc_id: docId,
p_util: util
})
if (error) throw error
const acao = data?.acao
meusVotos.value = {
...meusVotos.value,
[docId]: acao === 'removido' ? null : util
}
_atualizarContadorLocal(docId, util, acao)
} catch (e) {
console.warn('[useAjuda] erro ao votar:', e?.message || e)
}
}
function _atualizarContadorLocal (docId, util, acao) {
const ajustar = (doc) => {
if (!doc || doc.id !== docId) return doc
let { votos_util = 0, votos_nao_util = 0 } = doc
if (acao === 'registrado') {
if (util) votos_util++; else votos_nao_util++
} else if (acao === 'removido') {
if (util) votos_util = Math.max(0, votos_util - 1)
else votos_nao_util = Math.max(0, votos_nao_util - 1)
} else if (acao === 'atualizado') {
if (util) { votos_util++; votos_nao_util = Math.max(0, votos_nao_util - 1) }
else { votos_nao_util++; votos_util = Math.max(0, votos_util - 1) }
}
return { ...doc, votos_util, votos_nao_util }
}
sessionDocs.value = sessionDocs.value.map(ajustar)
allDocs.value = allDocs.value.map(ajustar)
if (currentDoc.value) {
currentDoc.value = { ...currentDoc.value, docs: currentDoc.value.docs.map(ajustar) }
}
cache.forEach((val, key) => {
if (val.docs.some(d => d.id === docId)) cache.delete(key)
})
docCache.delete(docId)
}
// ── Navegação interna ─────────────────────────────────────────
export async function navigateToDoc (docId, label = '') {
if (!docId) return
navStack.value.push({ currentDoc: currentDoc.value, label: label || 'Voltar' })
loading.value = true
try {
if (docCache.has(docId)) {
currentDoc.value = docCache.get(docId)
return
}
const { data, error } = await supabase
.from('saas_docs')
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
.eq('id', docId)
.eq('ativo', true)
.maybeSingle()
if (error) throw error
if (!data) { navStack.value.pop(); return }
const userIsAdmin = isCurrentUserAdmin()
if (data.tipo_acesso !== 'usuario' && !userIsAdmin) {
navStack.value.pop(); return
}
const relIds = data.docs_relacionados || []
let related = []
if (relIds.length) {
const { data: relData } = await supabase
.from('saas_docs')
.select('id, titulo, pagina_path')
.in('id', relIds)
.eq('ativo', true)
related = relData || []
}
const itens = await fetchFaqItensForDocs([data.id])
const entry = { docs: [data], relatedDocs: related, faqItens: itens }
docCache.set(docId, entry)
currentDoc.value = entry
} catch (e) {
console.warn('[useAjuda] erro ao navegar para doc:', e?.message || e)
navStack.value.pop()
} finally {
loading.value = false
}
}
export function navBack () {
const prev = navStack.value.pop()
if (prev) currentDoc.value = prev.currentDoc
}
export function invalidateAjudaCache (path) {
if (path) cache.delete(normalizePath(path))
else cache.clear()
docCache.clear()
}
// ── Composable público ────────────────────────────────────────
export function useAjuda () {
const hasAjuda = computed(() => true) // sempre habilitado
const allDocsHasMore = computed(() =>
allDocs.value.length < allDocsTotal.value - sessionDocs.value.length
)
function openDrawer () {
drawerOpen.value = true
currentDoc.value = null
if (!allDocs.value.length) loadAllDocs(0)
if (!globalFaq.value.length) loadGlobalFaq()
if (!Object.keys(meusVotos.value).length) loadMeusVotos()
}
function closeDrawer () {
drawerOpen.value = false
navStack.value = []
currentDoc.value = null
}
function loadMoreDocs () {
loadAllDocs(allDocsPage.value + 1)
}
return {
sessionDocs, sessionFaq, sessionPath,
allDocs, allDocsTotal, allDocsLoading, allDocsHasMore,
globalFaq, globalFaqLoading,
currentDoc, isHome, isNavigating, navStack,
drawerOpen, loading,
hasAjuda,
meusVotos,
openDrawer, closeDrawer,
navigateToDoc, navBack,
votar, loadMoreDocs,
}
}