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
+94
View File
@@ -0,0 +1,94 @@
// src/composables/useDocsHealth.js
// Singleton que computa métricas de saúde dos documentos.
// Critério de "atenção": mais de 30% dos votos são negativos
// (mínimo 3 votos totais para evitar falso-positivo com 1 voto negativo).
//
// O `countAtencao` é exportado como singleton para uso no menu SaaS:
//
// import { countAtencao } from '@/composables/useDocsHealth'
// // No lugar onde saasMenu() é chamado:
// saasMenu(sessionCtx, { mismatchCount, docsAtencaoCount: countAtencao.value })
import { ref, computed } from 'vue'
const THRESHOLD_NEGATIVO = 0.30 // 30%
const MIN_VOTOS = 3 // mínimo de votos para considerar
// Estado singleton — alimentado pelo SaasDocsPage após load()
const _docs = ref([])
// ── Classificação individual (exportada standalone p/ uso externo) ─
export function saudeDocItem (doc) {
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0)
if (total < MIN_VOTOS) return 'sem_dados'
const pctNeg = (doc.votos_nao_util || 0) / total
return pctNeg > THRESHOLD_NEGATIVO ? 'atencao' : 'ok'
}
// ── Singleton reativo — use no menu ou em qualquer lugar ──────
export const countAtencao = computed(() =>
_docs.value.filter(d => saudeDocItem(d) === 'atencao').length
)
export function useDocsHealth () {
function setDocs (docs) {
_docs.value = docs
}
function saudeDoc (doc) {
return saudeDocItem(doc)
}
function pctNegativo (doc) {
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0)
if (!total) return 0
return Math.round(((doc.votos_nao_util || 0) / total) * 100)
}
// ── Métricas globais ───────────────────────────────────────
const totalDocs = computed(() => _docs.value.length)
const docsAtencao = computed(() => _docs.value.filter(d => saudeDoc(d) === 'atencao'))
const docsOk = computed(() => _docs.value.filter(d => saudeDoc(d) === 'ok'))
const docsSemDados = computed(() => _docs.value.filter(d => saudeDoc(d) === 'sem_dados'))
// Doc mais útil (maior % positivo com mínimo de votos)
const docMaisUtil = computed(() => {
const comVotos = _docs.value.filter(d =>
(d.votos_util || 0) + (d.votos_nao_util || 0) >= MIN_VOTOS
)
if (!comVotos.length) return null
return comVotos.reduce((best, d) => {
const pct = (d.votos_util || 0) / ((d.votos_util || 0) + (d.votos_nao_util || 0))
const bestPct = (best.votos_util || 0) / ((best.votos_util || 0) + (best.votos_nao_util || 0))
return pct > bestPct ? d : best
})
})
// ── Ordenação por saúde ────────────────────────────────────
// Problemáticas primeiro → ok → sem dados
function sortBySaude (lista) {
const ordem = { atencao: 0, ok: 1, sem_dados: 2 }
return [...lista].sort((a, b) => {
const sa = saudeDoc(a)
const sb = saudeDoc(b)
if (ordem[sa] !== ordem[sb]) return ordem[sa] - ordem[sb]
return pctNegativo(b) - pctNegativo(a)
})
}
return {
setDocs,
saudeDoc,
pctNegativo,
totalDocs,
docsAtencao,
docsOk,
docsSemDados,
countAtencao,
docMaisUtil,
sortBySaude,
THRESHOLD_NEGATIVO,
MIN_VOTOS,
}
}
+354
View File
@@ -0,0 +1,354 @@
// src/composables/useAjuda.js
// Composable singleton para o drawer de ajuda.
// - 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,
}
}
+21
View File
@@ -0,0 +1,21 @@
// src/composables/useDocsAdmin.js
// Estado compartilhado para abrir o dialog de edição de um doc
// a partir de outra página (ex: SaasFaqPage → SaasDocsPage).
import { ref } from 'vue'
const pendingEditDocId = ref(null)
export function useDocsAdmin () {
function requestEditDoc (docId) {
pendingEditDocId.value = docId
}
function consumePendingEdit () {
const id = pendingEditDocId.value
pendingEditDocId.value = null
return id
}
return { pendingEditDocId, requestEditDoc, consumePendingEdit }
}
+119
View File
@@ -0,0 +1,119 @@
// src/composables/useFeriados.js
// Fonte única de verdade para feriados: nacionais (algoritmo) + municipais (Supabase).
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { getFeriadosNacionais } from '@/utils/feriadosBR'
export function useFeriados () {
const ano = ref(new Date().getFullYear())
const loading = ref(false)
const municipais = ref([]) // linhas da tabela `feriados`
// ── Nacionais (algoritmo, sem DB) ─────────────────────────
const nacionais = computed(() =>
getFeriadosNacionais(ano.value).map(f => ({ ...f, tipo: 'nacional' }))
)
// ── Todos juntos, ordenados por data ─────────────────────
const todos = computed(() => [
...nacionais.value,
...municipais.value.map(f => ({ ...f, tipo: f.tipo || 'municipal' }))
].sort((a, b) => a.data.localeCompare(b.data)))
// ── Feriados de um mês (112) ─────────────────────────────
function doMes (mes) {
const m = String(mes).padStart(2, '0')
const prefix = `${ano.value}-${m}`
return todos.value.filter(f => f.data.startsWith(prefix))
}
// ── Próximos N dias ───────────────────────────────────────
function proximos (dias = 30) {
const hoje = new Date()
const limite = new Date(hoje)
limite.setDate(limite.getDate() + dias)
const hojeISO = toISO(hoje)
const limiteISO = toISO(limite)
return todos.value.filter(f => f.data >= hojeISO && f.data <= limiteISO)
}
function toISO (d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
// ── Load municipais do Supabase ───────────────────────────
async function load (tenantId, year) {
if (year) ano.value = year
if (!tenantId) return
loading.value = true
try {
// Busca feriados do tenant + feriados globais (tenant_id null, cadastrados pelo SAAS)
const { data, error } = await supabase
.from('feriados')
.select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`)
.order('data')
if (error) throw error
municipais.value = data || []
} finally {
loading.value = false
}
}
// ── Criar feriado municipal ───────────────────────────────
async function criar (payload) {
const { data, error } = await supabase
.from('feriados')
.insert(payload)
.select()
.single()
if (error) throw error
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data))
return data
}
// ── Remover feriado municipal ─────────────────────────────
async function remover (id) {
const { error } = await supabase.from('feriados').delete().eq('id', id)
if (error) throw error
municipais.value = municipais.value.filter(f => f.id !== id)
}
// ── Verificar duplicata ───────────────────────────────────
function isDuplicata (data, nome) {
return todos.value.some(f => f.data === data && f.nome.trim().toLowerCase() === nome.trim().toLowerCase())
}
// ── Converter para eventos do FullCalendar (background) ──
function toFcEvents (lista) {
return lista.map(f => ({
id: `feriado_${f.id || f.data}_${f.nome}`,
title: f.nome,
start: f.data,
allDay: true,
display: 'background',
color: 'rgba(251, 191, 36, 0.25)',
extendedProps: { _feriado: true, tipo: f.tipo }
}))
}
const fcEvents = computed(() => toFcEvents(todos.value))
return {
ano,
loading,
nacionais,
municipais,
todos,
fcEvents,
load,
criar,
remover,
doMes,
proximos,
isDuplicata
}
}