1227 lines
54 KiB
Vue
1227 lines
54 KiB
Vue
<!-- src/views/pages/saas/SaasDocsPage.vue -->
|
|
<!-- SaaS admin: CRUD de documentação dinâmica exibida nas páginas do sistema -->
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { supabase } from '@/lib/supabase/client'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import { useConfirm } from 'primevue/useconfirm'
|
|
import Editor from 'primevue/editor'
|
|
import { FilterMatchMode } from '@primevue/core/api'
|
|
import { PAGE_OPTIONS } from '@/utils/menuPageOptions'
|
|
import { invalidateAjudaCache } from '@/composables/useAjuda'
|
|
import { useDocsAdmin } from '@/composables/useDocsAdmin'
|
|
import { useDocsHealth } from '@/composables/useDocsHealth'
|
|
|
|
const toast = useToast()
|
|
const confirm = useConfirm()
|
|
const { consumePendingEdit } = useDocsAdmin()
|
|
const {
|
|
setDocs, saudeDoc, pctNegativo,
|
|
totalDocs, docsAtencao, docsOk, docsSemDados,
|
|
countAtencao, docMaisUtil, sortBySaude,
|
|
} = useDocsHealth()
|
|
|
|
// ── Estado ────────────────────────────────────────────────────
|
|
const loading = ref(false)
|
|
const docs = ref([])
|
|
|
|
// ── Dialog ────────────────────────────────────────────────────
|
|
const dlgOpen = ref(false)
|
|
const saving = ref(false)
|
|
const editId = ref(null)
|
|
const activeTab = ref(0) // 0 = Conteúdo, 1 = FAQ, 2 = Mídias, 3 = Configurações
|
|
const form = ref(emptyForm())
|
|
|
|
function emptyForm () {
|
|
return {
|
|
titulo: '',
|
|
conteudo: '',
|
|
categoria: '',
|
|
exibir_no_faq: false,
|
|
tipo_acesso: 'usuario',
|
|
pagina_path: null,
|
|
medias: [],
|
|
docs_relacionados: [],
|
|
ativo: true,
|
|
ordem: 0,
|
|
_custom_path: '',
|
|
_faq_itens: []
|
|
}
|
|
}
|
|
|
|
const tipoAcessoOptions = [
|
|
{ label: 'Usuários (todos)', value: 'usuario' },
|
|
{ label: 'Admin (clinic_admin, tenant_admin, saas)', value: 'admin' },
|
|
]
|
|
|
|
// ── Categorias dinâmicas ──────────────────────────────────────
|
|
const categorias = computed(() => {
|
|
const set = new Set(docs.value.map(d => d.categoria).filter(Boolean))
|
|
return [...set].sort()
|
|
})
|
|
|
|
// ── Opções de página ──────────────────────────────────────────
|
|
const pageSelectOptions = computed(() => [
|
|
...PAGE_OPTIONS,
|
|
{ label: '— Caminho personalizado —', path: '__custom__' }
|
|
])
|
|
|
|
const isCustomPath = computed(() => form.value.pagina_path === '__custom__')
|
|
|
|
// Contagem de docs por path (excluindo o próprio doc em edição)
|
|
const docsCountByPath = computed(() => {
|
|
const map = new Map()
|
|
for (const d of docs.value) {
|
|
if (d.id === editId.value) continue
|
|
if (!d.pagina_path) continue
|
|
map.set(d.pagina_path, (map.get(d.pagina_path) || 0) + 1)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const resolvedPath = computed(() => {
|
|
if (isCustomPath.value) return form.value._custom_path.trim()
|
|
return form.value.pagina_path || ''
|
|
})
|
|
|
|
// ── Docs relacionados (MultiSelect) ───────────────────────────
|
|
const docsRelOptions = computed(() =>
|
|
docs.value
|
|
.filter(d => d.id !== editId.value)
|
|
.map(d => ({
|
|
label: `${d.titulo} · ${d.pagina_path}`,
|
|
value: d.id
|
|
}))
|
|
)
|
|
|
|
const formValid = computed(() =>
|
|
!!form.value.titulo.trim() && !!resolvedPath.value
|
|
)
|
|
|
|
// ── Dialog abrir ──────────────────────────────────────────────
|
|
async function abrirDialog (doc = null) {
|
|
activeTab.value = 0
|
|
if (doc) {
|
|
editId.value = doc.id
|
|
const isCustom = !PAGE_OPTIONS.find(p => p.path === doc.pagina_path)
|
|
form.value = {
|
|
titulo: doc.titulo || '',
|
|
conteudo: doc.conteudo || '',
|
|
categoria: doc.categoria || '',
|
|
exibir_no_faq: doc.exibir_no_faq ?? false,
|
|
tipo_acesso: doc.tipo_acesso || 'usuario',
|
|
pagina_path: isCustom ? '__custom__' : doc.pagina_path,
|
|
medias: Array.isArray(doc.medias)
|
|
? JSON.parse(JSON.stringify(doc.medias))
|
|
: [],
|
|
docs_relacionados: Array.isArray(doc.docs_relacionados)
|
|
? [...doc.docs_relacionados]
|
|
: [],
|
|
ativo: doc.ativo ?? true,
|
|
ordem: doc.ordem ?? 0,
|
|
_custom_path: isCustom ? doc.pagina_path : '',
|
|
_faq_itens: []
|
|
}
|
|
// Carrega FAQ itens existentes
|
|
await carregarFaqItens(doc.id)
|
|
} else {
|
|
editId.value = null
|
|
form.value = emptyForm()
|
|
}
|
|
dlgOpen.value = true
|
|
}
|
|
|
|
// ── FAQ Itens ─────────────────────────────────────────────────
|
|
const faqLoading = ref(false)
|
|
|
|
async function carregarFaqItens (docId) {
|
|
faqLoading.value = true
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('saas_faq_itens')
|
|
.select('id, pergunta, resposta, ordem, ativo')
|
|
.eq('doc_id', docId)
|
|
.order('ordem')
|
|
if (error) throw error
|
|
form.value._faq_itens = (data || []).map(item => ({
|
|
...item,
|
|
_dirty: false, // marcador de alteração local
|
|
_novo: false
|
|
}))
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro ao carregar FAQ', detail: e?.message, life: 3000 })
|
|
} finally {
|
|
faqLoading.value = false
|
|
}
|
|
}
|
|
|
|
function addFaqItem () {
|
|
form.value._faq_itens.push({
|
|
id: null,
|
|
pergunta: '',
|
|
resposta: '',
|
|
ordem: form.value._faq_itens.length,
|
|
ativo: true,
|
|
_dirty: true,
|
|
_novo: true
|
|
})
|
|
}
|
|
|
|
function removeFaqItem (idx) {
|
|
form.value._faq_itens.splice(idx, 1)
|
|
}
|
|
|
|
// Salva os itens FAQ após salvar o doc
|
|
async function salvarFaqItens (docId) {
|
|
const itens = form.value._faq_itens
|
|
|
|
// Identifica itens para deletar (que existiam mas foram removidos)
|
|
// — isso é tratado pelo splice + controle de _novo
|
|
|
|
// Upsert dos que existem na lista
|
|
for (let i = 0; i < itens.length; i++) {
|
|
const item = itens[i]
|
|
if (!item.pergunta.trim()) continue
|
|
|
|
const payload = {
|
|
doc_id: docId,
|
|
pergunta: item.pergunta.trim(),
|
|
resposta: item.resposta || '',
|
|
ordem: i,
|
|
ativo: item.ativo ?? true,
|
|
updated_at: new Date().toISOString()
|
|
}
|
|
|
|
if (item._novo || !item.id) {
|
|
const { data, error } = await supabase
|
|
.from('saas_faq_itens')
|
|
.insert(payload)
|
|
.select('id')
|
|
.single()
|
|
if (error) throw error
|
|
itens[i].id = data.id
|
|
itens[i]._novo = false
|
|
} else {
|
|
const { error } = await supabase
|
|
.from('saas_faq_itens')
|
|
.update(payload)
|
|
.eq('id', item.id)
|
|
if (error) throw error
|
|
}
|
|
itens[i]._dirty = false
|
|
}
|
|
|
|
// Remove do banco itens que foram deletados da lista
|
|
// (busca ids que existiam e não estão mais na lista)
|
|
if (editId.value) {
|
|
const idsAtuais = itens.map(i => i.id).filter(Boolean)
|
|
const { data: existentes } = await supabase
|
|
.from('saas_faq_itens')
|
|
.select('id')
|
|
.eq('doc_id', docId)
|
|
const existentesIds = (existentes || []).map(e => e.id)
|
|
const paraExcluir = existentesIds.filter(id => !idsAtuais.includes(id))
|
|
if (paraExcluir.length) {
|
|
await supabase
|
|
.from('saas_faq_itens')
|
|
.delete()
|
|
.in('id', paraExcluir)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Mídias ────────────────────────────────────────────────────
|
|
function addMedia () { form.value.medias.push({ tipo: 'imagem', url: '' }) }
|
|
function removeMedia (idx) { form.value.medias.splice(idx, 1) }
|
|
|
|
const uploadingIdx = ref(new Set())
|
|
const fileInputRefs = ref({})
|
|
|
|
function triggerUpload (idx) {
|
|
fileInputRefs.value[idx]?.click()
|
|
}
|
|
|
|
async function uploadImagem (idx, event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
event.target.value = ''
|
|
const ext = file.name.split('.').pop()
|
|
const nome = `${crypto.randomUUID()}.${ext}`
|
|
const path = `doc-images/${nome}`
|
|
uploadingIdx.value = new Set([...uploadingIdx.value, idx])
|
|
try {
|
|
const { error: upErr } = await supabase.storage
|
|
.from('saas-docs')
|
|
.upload(path, file, { upsert: false, contentType: file.type })
|
|
if (upErr) throw upErr
|
|
const { data } = supabase.storage.from('saas-docs').getPublicUrl(path)
|
|
form.value.medias[idx].url = data.publicUrl
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message, life: 4000 })
|
|
} finally {
|
|
const next = new Set(uploadingIdx.value)
|
|
next.delete(idx)
|
|
uploadingIdx.value = next
|
|
}
|
|
}
|
|
|
|
// ── Salvar ────────────────────────────────────────────────────
|
|
async function salvar () {
|
|
if (!formValid.value) return
|
|
saving.value = true
|
|
try {
|
|
const payload = {
|
|
titulo: form.value.titulo.trim(),
|
|
conteudo: form.value.conteudo.trim(),
|
|
categoria: form.value.categoria.trim() || null,
|
|
exibir_no_faq: form.value.exibir_no_faq,
|
|
tipo_acesso: form.value.tipo_acesso,
|
|
pagina_path: resolvedPath.value,
|
|
medias: form.value.medias.filter(m => m.url.trim()),
|
|
docs_relacionados: form.value.docs_relacionados,
|
|
ativo: form.value.ativo,
|
|
ordem: Number(form.value.ordem) || 0,
|
|
updated_at: new Date().toISOString()
|
|
}
|
|
|
|
let docId = editId.value
|
|
|
|
if (editId.value) {
|
|
const { error } = await supabase
|
|
.from('saas_docs')
|
|
.update(payload)
|
|
.eq('id', editId.value)
|
|
if (error) throw error
|
|
const idx = docs.value.findIndex(d => d.id === editId.value)
|
|
if (idx >= 0) docs.value[idx] = { ...docs.value[idx], ...payload, id: editId.value }
|
|
toast.add({ severity: 'success', summary: 'Documento atualizado', life: 1800 })
|
|
} else {
|
|
const { data, error } = await supabase
|
|
.from('saas_docs')
|
|
.insert(payload)
|
|
.select()
|
|
.single()
|
|
if (error) throw error
|
|
docId = data.id
|
|
docs.value = [data, ...docs.value]
|
|
toast.add({ severity: 'success', summary: 'Documento criado', life: 1800 })
|
|
}
|
|
|
|
// Salva itens FAQ
|
|
await salvarFaqItens(docId)
|
|
|
|
invalidateAjudaCache(resolvedPath.value)
|
|
dlgOpen.value = false
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ── Excluir ───────────────────────────────────────────────────
|
|
function excluir (doc) {
|
|
confirm.require({
|
|
header: 'Excluir documento',
|
|
message: `Tem certeza que deseja excluir "${doc.titulo}"? Esta ação não pode ser desfeita.`,
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptLabel: 'Excluir',
|
|
rejectLabel: 'Cancelar',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase
|
|
.from('saas_docs')
|
|
.delete()
|
|
.eq('id', doc.id)
|
|
if (error) throw error
|
|
docs.value = docs.value.filter(d => d.id !== doc.id)
|
|
invalidateAjudaCache(doc.pagina_path)
|
|
toast.add({ severity: 'success', summary: 'Documento removido', life: 1800 })
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro ao excluir', detail: e?.message, life: 3000 })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Load ──────────────────────────────────────────────────────
|
|
async function load () {
|
|
loading.value = true
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('saas_docs')
|
|
.select('*')
|
|
.order('pagina_path')
|
|
.order('ordem')
|
|
if (error) throw error
|
|
docs.value = data || []
|
|
console.log('docs carregados:', docs.value.map(d => ({ titulo: d.titulo, votos_util: d.votos_util, votos_nao_util: d.votos_nao_util })))
|
|
setDocs(docs.value)
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// ── Docs ordenados por saúde (problemáticos primeiro) ────────
|
|
const filtroAtencao = ref(false) // toggle para mostrar só problemáticos
|
|
|
|
const docsFiltrados = computed(() => {
|
|
let lista = docs.value
|
|
if (filtroAtencao.value) lista = lista.filter(d => saudeDoc(d) === 'atencao')
|
|
return sortBySaude(lista)
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await load()
|
|
// Verifica se veio da página de FAQ com um doc para editar
|
|
const pendingId = consumePendingEdit()
|
|
if (pendingId) {
|
|
const doc = docs.value.find(d => d.id === pendingId)
|
|
if (doc) abrirDialog(doc)
|
|
}
|
|
})
|
|
|
|
// ── Busca / Filtros ───────────────────────────────────────────
|
|
const filters = ref({
|
|
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
|
|
})
|
|
|
|
// ── Formatação de datas ───────────────────────────────────────
|
|
function fmtDate (iso) {
|
|
if (!iso) return '—'
|
|
return new Date(iso).toLocaleString('pt-BR', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
// ── Preview ───────────────────────────────────────────────────
|
|
const previewDoc = ref(null)
|
|
const previewOpen = ref(false)
|
|
const previewFaqItens = ref([])
|
|
|
|
async function abrirPreview (doc) {
|
|
previewDoc.value = doc
|
|
previewOpen.value = true
|
|
// Carrega itens FAQ para o preview
|
|
const { data } = await supabase
|
|
.from('saas_faq_itens')
|
|
.select('id, pergunta, resposta, ordem')
|
|
.eq('doc_id', doc.id)
|
|
.eq('ativo', true)
|
|
.order('ordem')
|
|
previewFaqItens.value = data || []
|
|
}
|
|
|
|
const previewRelated = computed(() => {
|
|
const ids = previewDoc.value?.docs_relacionados || []
|
|
return docs.value.filter(d => ids.includes(d.id))
|
|
})
|
|
|
|
const previewFaqAbertos = ref({})
|
|
function togglePreviewFaq (id) {
|
|
previewFaqAbertos.value[id] = !previewFaqAbertos.value[id]
|
|
}
|
|
|
|
// ── Dialog de Prompt ────────────────────────────────────────────
|
|
const promptDlgOpen = ref(false)
|
|
|
|
// ── Importar JSON ─────────────────────────────────────────────
|
|
const jsonFileInputRef = ref(null)
|
|
|
|
function copiarPrompt () {
|
|
const pre = document.querySelector('.prompt-pre')
|
|
if (!pre) return
|
|
navigator.clipboard.writeText(pre.textContent.trim()).then(() => {
|
|
toast.add({ severity: 'success', summary: 'Prompt copiado!', detail: 'Cole no Claude e anexe o arquivo .vue', life: 2500 })
|
|
})
|
|
}
|
|
|
|
function triggerJsonImport () {
|
|
jsonFileInputRef.value?.click()
|
|
}
|
|
|
|
async function onJsonFileChange (event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
event.target.value = ''
|
|
|
|
try {
|
|
const text = await file.text()
|
|
const json = JSON.parse(text)
|
|
|
|
// Valida campos mínimos
|
|
if (!json.titulo && !json.conteudo) {
|
|
toast.add({ severity: 'warn', summary: 'JSON inválido', detail: 'O arquivo não parece ser uma documentação válida.', life: 3500 })
|
|
return
|
|
}
|
|
|
|
// Detecta se pagina_path é custom (não existe em PAGE_OPTIONS)
|
|
const isCustom = json.pagina_path && !PAGE_OPTIONS.find(p => p.path === json.pagina_path)
|
|
|
|
// Popula form (mesmo padrão do abrirDialog)
|
|
editId.value = null
|
|
form.value = {
|
|
titulo: json.titulo || '',
|
|
conteudo: json.conteudo || '',
|
|
categoria: json.categoria || '',
|
|
exibir_no_faq: json.exibir_no_faq ?? false,
|
|
tipo_acesso: json.tipo_acesso || 'usuario',
|
|
pagina_path: isCustom ? '__custom__' : (json.pagina_path || null),
|
|
medias: Array.isArray(json.medias) ? JSON.parse(JSON.stringify(json.medias)) : [],
|
|
docs_relacionados: Array.isArray(json.docs_relacionados) ? [...json.docs_relacionados] : [],
|
|
ativo: json.ativo ?? true,
|
|
ordem: json.ordem ?? 0,
|
|
_custom_path: isCustom ? json.pagina_path : '',
|
|
_faq_itens: Array.isArray(json._faq_itens)
|
|
? json._faq_itens.map((item, i) => ({
|
|
id: null,
|
|
pergunta: item.pergunta || '',
|
|
resposta: item.resposta || '',
|
|
ordem: item.ordem ?? i,
|
|
ativo: item.ativo ?? true,
|
|
_dirty: true,
|
|
_novo: true
|
|
}))
|
|
: []
|
|
}
|
|
|
|
activeTab.value = 0
|
|
dlgOpen.value = true
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'JSON importado',
|
|
detail: `"${json.titulo}" carregado. Revise e salve.`,
|
|
life: 3000
|
|
})
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro ao ler JSON', detail: e?.message, life: 4000 })
|
|
}
|
|
}
|
|
|
|
// ── Helpers de exibição ───────────────────────────────────────
|
|
function tipoLabel (tipo) {
|
|
return tipo === 'admin' ? 'Somente Admin' : 'Usuários'
|
|
}
|
|
function tipoSeverity (tipo) {
|
|
return tipo === 'admin' ? 'danger' : 'info'
|
|
}
|
|
function relatedCount (doc) {
|
|
return doc.docs_relacionados?.length || 0
|
|
}
|
|
function mediasCount (doc) {
|
|
return Array.isArray(doc.medias) ? doc.medias.filter(m => m.url).length : 0
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Toast />
|
|
<ConfirmDialog />
|
|
|
|
<!-- Input oculto para importação de JSON -->
|
|
<input
|
|
ref="jsonFileInputRef"
|
|
type="file"
|
|
accept=".json,application/json"
|
|
style="display:none"
|
|
@change="onJsonFileChange"
|
|
/>
|
|
|
|
<!-- Sentinel -->
|
|
<div class="h-px" />
|
|
|
|
<!-- Hero sticky -->
|
|
<div
|
|
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
|
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
|
>
|
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-blue-400/10" />
|
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
|
|
</div>
|
|
<div class="relative z-10 flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
|
|
<i class="pi pi-question-circle text-xl text-blue-500" />
|
|
</div>
|
|
<div>
|
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Documentação do Sistema</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Artigos de ajuda exibidos dinamicamente nas páginas.</div>
|
|
</div>
|
|
</div>
|
|
<!-- Desktop actions -->
|
|
<div class="hidden xl:flex items-center gap-2">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
|
|
<Button
|
|
icon="pi pi-upload"
|
|
label="Importar JSON"
|
|
severity="secondary"
|
|
outlined
|
|
class="rounded-full"
|
|
v-tooltip.bottom="'Carrega um arquivo .json gerado pelo assistente'"
|
|
@click="triggerJsonImport"
|
|
/>
|
|
<Button
|
|
icon="pi pi-comment"
|
|
label="Prompt"
|
|
severity="secondary"
|
|
outlined
|
|
class="rounded-full"
|
|
v-tooltip.bottom="'Ver instruções para gerar documentação com IA'"
|
|
@click="promptDlgOpen = true"
|
|
/>
|
|
<Button icon="pi pi-plus" label="Novo documento" class="rounded-full" @click="abrirDialog()" />
|
|
</div>
|
|
<!-- Mobile actions -->
|
|
<div class="flex xl:hidden items-center gap-2">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
|
|
<Button icon="pi pi-upload" severity="secondary" outlined rounded v-tooltip.bottom="'Importar JSON'" @click="triggerJsonImport" />
|
|
<Button icon="pi pi-comment" severity="secondary" outlined rounded v-tooltip.bottom="'Prompt IA'" @click="promptDlgOpen = true" />
|
|
<Button icon="pi pi-plus" rounded @click="abrirDialog()" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
|
|
|
<!-- ── Cards de saúde ──────────────────────────────────── -->
|
|
<div class="flex flex-wrap gap-2.5">
|
|
<!-- Total -->
|
|
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] min-w-[110px]">
|
|
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totalDocs }}</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Total de docs</div>
|
|
</div>
|
|
|
|
<!-- OK -->
|
|
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border min-w-[110px]"
|
|
style="border-color: color-mix(in srgb, #22c55e 25%, transparent); background: color-mix(in srgb, #22c55e 5%, var(--surface-card))">
|
|
<div class="text-[1.35rem] font-bold leading-none text-green-600">{{ docsOk.length }}</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 flex items-center gap-1">
|
|
<i class="pi pi-thumbs-up text-green-500 text-[10px]" />Bem avaliadas
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Atenção — clicável para filtrar -->
|
|
<div
|
|
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border min-w-[110px] cursor-pointer transition-all"
|
|
:style="filtroAtencao
|
|
? 'border-color: #ef4444; background: color-mix(in srgb, #ef4444 10%, var(--surface-card)); box-shadow: 0 0 0 3px color-mix(in srgb, #ef4444 15%, transparent)'
|
|
: 'border-color: color-mix(in srgb, #ef4444 25%, transparent); background: color-mix(in srgb, #ef4444 5%, var(--surface-card))'"
|
|
v-tooltip.bottom="filtroAtencao ? 'Clique para ver todas' : 'Clique para filtrar'"
|
|
@click="filtroAtencao = !filtroAtencao"
|
|
>
|
|
<div class="text-[1.35rem] font-bold leading-none text-red-500 flex items-center gap-1.5">
|
|
{{ docsAtencao.length }}
|
|
<i v-if="docsAtencao.length" class="pi pi-exclamation-triangle text-[1rem] text-red-400" />
|
|
</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 flex items-center gap-1">
|
|
<i class="pi pi-thumbs-down text-red-400 text-[10px]" />Precisam de atenção
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sem dados -->
|
|
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] min-w-[110px]">
|
|
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color-secondary)]">{{ docsSemDados.length }}</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">Sem votos</div>
|
|
</div>
|
|
|
|
<!-- Mais útil -->
|
|
<div v-if="docMaisUtil" class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-0"
|
|
style="border-color: color-mix(in srgb, var(--primary-color) 30%, transparent); background: color-mix(in srgb, var(--primary-color) 5%, var(--surface-card))">
|
|
<div class="text-[1rem] font-semibold truncate text-[var(--primary-color)]">{{ docMaisUtil.titulo }}</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 flex items-center gap-1">
|
|
<i class="pi pi-star text-amber-400 text-[10px]" />Mais útil
|
|
<span class="ml-1 opacity-60">({{ docMaisUtil.votos_util }} 👍)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Banner de alerta quando há docs com problema -->
|
|
<div v-if="docsAtencao.length && !filtroAtencao"
|
|
class="flex items-center gap-2.5 px-3.5 py-2.5 rounded-md border text-[1rem] text-[var(--text-color)]"
|
|
style="background: color-mix(in srgb, #f59e0b 8%, var(--surface-card)); border-color: color-mix(in srgb, #f59e0b 35%, transparent)"
|
|
>
|
|
<i class="pi pi-exclamation-triangle text-amber-500 shrink-0" />
|
|
<span>
|
|
<strong>{{ docsAtencao.length }} documento{{ docsAtencao.length > 1 ? 's' : '' }}</strong>
|
|
{{ docsAtencao.length > 1 ? 'estão recebendo' : 'está recebendo' }} mais de 30% de votos negativos.
|
|
<button class="underline text-[var(--primary-color)] ml-1" @click="filtroAtencao = true">Ver agora</button>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- ── Tabela ──────────────────────────────────────────── -->
|
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
|
<DataTable
|
|
:value="docsFiltrados"
|
|
:loading="loading"
|
|
v-model:filters="filters"
|
|
:globalFilterFields="['titulo', 'pagina_path', 'categoria']"
|
|
filterDisplay="menu"
|
|
paginator
|
|
:rows="10"
|
|
:rowsPerPageOptions="[5, 10, 25, 50]"
|
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
|
dataKey="id"
|
|
removableSort
|
|
class=""
|
|
:emptyMessage="loading ? ' ' : filtroAtencao ? 'Nenhum documento com problema.' : 'Nenhum documento encontrado.'"
|
|
:rowClass="(data) => saudeDoc(data) === 'atencao' ? 'row-atencao' : ''"
|
|
>
|
|
<template #header>
|
|
<div class="flex items-center justify-between px-1 py-1 gap-2">
|
|
<div v-if="filtroAtencao" class="flex items-center gap-2">
|
|
<span class="text-[1rem] font-semibold text-red-500 flex items-center gap-1">
|
|
<i class="pi pi-filter" />Filtrando: precisam de atenção
|
|
</span>
|
|
<button class="text-[1rem] text-[var(--text-color-secondary)] underline" @click="filtroAtencao = false">
|
|
Limpar
|
|
</button>
|
|
</div>
|
|
<div v-else class="flex-1" />
|
|
<IconField>
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText v-model="filters['global'].value" placeholder="Buscar por título ou página…" class="w-72" />
|
|
</IconField>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Título + badges -->
|
|
<Column field="titulo" header="Documento" sortable style="min-width: 220px">
|
|
<template #body="{ data }">
|
|
<div class="flex flex-col gap-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="font-semibold text-[1rem]">{{ data.titulo }}</span>
|
|
<Tag :value="tipoLabel(data.tipo_acesso)" :severity="tipoSeverity(data.tipo_acesso)" class="text-[1rem]" />
|
|
<Tag v-if="!data.ativo" value="Inativo" severity="secondary" class="text-[1rem]" />
|
|
<span v-if="data.exibir_no_faq" class="inline-flex items-center text-[1rem] font-bold px-1.5 py-px rounded border" style="background: color-mix(in srgb, var(--primary-color) 15%, transparent); color: var(--primary-color); border-color: color-mix(in srgb, var(--primary-color) 30%, transparent); letter-spacing: 0.05em">FAQ</span>
|
|
</div>
|
|
<div class="flex items-center gap-3 text-[1rem] text-[var(--text-color-secondary)] opacity-70">
|
|
<span v-if="data.categoria"><i class="pi pi-tag mr-1" />{{ data.categoria }}</span>
|
|
<span v-if="relatedCount(data)"><i class="pi pi-link mr-1" />{{ relatedCount(data) }}</span>
|
|
<span v-if="mediasCount(data)"><i class="pi pi-image mr-1" />{{ mediasCount(data) }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<!-- Página -->
|
|
<Column field="pagina_path" header="Página" sortable style="min-width: 160px">
|
|
<template #body="{ data }">
|
|
<span class="text-[1rem] font-mono text-[var(--text-color-secondary)]">{{ data.pagina_path }}</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<!-- Saúde / Votos -->
|
|
<Column header="Votos" style="min-width: 130px; white-space: nowrap">
|
|
<template #body="{ data }">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Sem votos suficientes -->
|
|
<span v-if="saudeDoc(data) === 'sem_dados'" class="text-[1rem] text-[var(--text-color-secondary)] opacity-40 italic">
|
|
sem dados
|
|
</span>
|
|
<template v-else>
|
|
<!-- Indicador de saúde -->
|
|
<div
|
|
class="w-2 h-2 rounded-full shrink-0"
|
|
:class="saudeDoc(data) === 'atencao' ? 'bg-red-500 animate-pulse' : 'bg-green-500'"
|
|
v-tooltip.top="saudeDoc(data) === 'atencao'
|
|
? `${pctNegativo(data)}% negativos — precisa de atenção`
|
|
: `${pctNegativo(data)}% negativos — OK`"
|
|
/>
|
|
<!-- Contadores -->
|
|
<div class="flex items-center gap-1.5 text-[1rem]">
|
|
<span class="flex items-center gap-0.5 text-green-600">
|
|
<i class="pi pi-thumbs-up text-[10px]" />{{ data.votos_util || 0 }}
|
|
</span>
|
|
<span class="text-[var(--surface-border)]">·</span>
|
|
<span class="flex items-center gap-0.5 text-red-500">
|
|
<i class="pi pi-thumbs-down text-[10px]" />{{ data.votos_nao_util || 0 }}
|
|
</span>
|
|
</div>
|
|
<!-- % negativo para docs em atenção -->
|
|
<span v-if="saudeDoc(data) === 'atencao'" class="text-[1rem] text-red-400 font-semibold">
|
|
{{ pctNegativo(data) }}%
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<!-- Modificado em -->
|
|
<Column field="updated_at" header="Modificado em" sortable style="min-width: 130px; white-space: nowrap">
|
|
<template #body="{ data }">
|
|
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ fmtDate(data.updated_at) }}</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<!-- Ações -->
|
|
<Column header="" style="width: 110px; text-align: right">
|
|
<template #body="{ data }">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<Button icon="pi pi-eye" text rounded size="small" severity="info" v-tooltip.top="'Visualizar'" @click="abrirPreview(data)" />
|
|
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" v-tooltip.top="'Editar'" @click="abrirDialog(data)" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'Excluir'" @click="excluir(data)" />
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</div>
|
|
|
|
</div><!-- /px-3 content wrapper -->
|
|
|
|
<!-- ══ Dialog de preview ══════════════════════════════════ -->
|
|
<Dialog
|
|
v-model:visible="previewOpen"
|
|
modal
|
|
:draggable="false"
|
|
:style="{ width: '520px', maxWidth: '95vw' }"
|
|
@hide="previewFaqAbertos = {}"
|
|
>
|
|
<template #header>
|
|
<div class="flex items-center gap-2">
|
|
<i class="pi pi-question-circle text-blue-500" />
|
|
<span class="font-semibold">{{ previewDoc?.titulo }}</span>
|
|
<Tag :value="tipoLabel(previewDoc?.tipo_acesso)" :severity="tipoSeverity(previewDoc?.tipo_acesso)" class="text-[1rem] ml-1" />
|
|
<span v-if="previewDoc?.exibir_no_faq" class="inline-flex items-center text-[1rem] font-bold px-1.5 py-px rounded border ml-1" style="background: color-mix(in srgb, var(--primary-color) 15%, transparent); color: var(--primary-color); border-color: color-mix(in srgb, var(--primary-color) 30%, transparent); letter-spacing: 0.05em">FAQ</span>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="previewDoc" class="flex flex-col gap-4 py-1">
|
|
<div class="text-[1rem] font-mono text-[var(--text-color-secondary)] bg-[var(--surface-ground)] rounded-md px-3 py-2">
|
|
<i class="pi pi-map-marker mr-1 opacity-60" />{{ previewDoc.pagina_path }}
|
|
<span v-if="previewDoc.categoria" class="ml-2 opacity-60">· {{ previewDoc.categoria }}</span>
|
|
</div>
|
|
|
|
<div v-if="previewDoc.conteudo" class="text-[1rem] leading-relaxed text-[var(--text-color-secondary)] ql-content" v-html="previewDoc.conteudo" />
|
|
|
|
<!-- Mídias -->
|
|
<template v-if="previewDoc.medias?.length">
|
|
<div v-for="(m, idx) in previewDoc.medias.filter(m => m.url)" :key="idx">
|
|
<img v-if="m.tipo === 'imagem'" :src="m.url" class="w-full rounded-md border border-[var(--surface-border)]" :alt="previewDoc.titulo" />
|
|
<div v-else-if="m.tipo === 'video'" class="relative w-full rounded-md overflow-hidden border border-[var(--surface-border)]" style="padding-top: 56.25%">
|
|
<iframe :src="m.url" frameborder="0" allowfullscreen class="absolute inset-0 w-full h-full" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- FAQ itens preview -->
|
|
<div v-if="previewFaqItens.length" class="border-t border-[var(--surface-border)] pt-3">
|
|
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
|
|
<i class="pi pi-comments mr-1" />Perguntas frequentes
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<div
|
|
v-for="item in previewFaqItens"
|
|
:key="item.id"
|
|
class="rounded-md overflow-hidden mb-px border bg-[var(--surface-ground)]"
|
|
:style="previewFaqAbertos[item.id] ? 'border-color: color-mix(in srgb, var(--primary-color) 30%, transparent)' : 'border-color: var(--surface-border)'"
|
|
>
|
|
<button
|
|
class="w-full flex items-center justify-between gap-2 px-3 py-2 text-[1rem] font-medium bg-transparent border-none cursor-pointer text-left text-[var(--text-color)]"
|
|
@click="togglePreviewFaq(item.id)"
|
|
>
|
|
<span>{{ item.pergunta }}</span>
|
|
<i class="pi opacity-40 shrink-0" :class="previewFaqAbertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
|
</button>
|
|
<div v-if="previewFaqAbertos[item.id] && item.resposta" class="px-3 pb-2 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed ql-content" v-html="item.resposta" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Veja também -->
|
|
<div v-if="previewRelated.length" class="border-t border-[var(--surface-border)] pt-3">
|
|
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
|
|
<i class="pi pi-link mr-1" />Veja também
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<div v-for="rel in previewRelated" :key="rel.id" class="flex items-center gap-2 px-3 py-2 rounded-md bg-[var(--surface-ground)] text-[1rem] text-[var(--primary-color)]">
|
|
<i class="pi pi-arrow-right" />
|
|
<span class="font-medium">{{ rel.titulo }}</span>
|
|
<span class="text-[1rem] text-[var(--text-color-secondary)] font-mono ml-auto">{{ rel.pagina_path }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Fechar" severity="secondary" outlined @click="previewOpen = false" />
|
|
<Button label="Editar" icon="pi pi-pencil" @click="previewOpen = false; abrirDialog(previewDoc)" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- ══ Dialog de criação / edição ═════════════════════════ -->
|
|
<Dialog
|
|
v-model:visible="dlgOpen"
|
|
modal
|
|
:draggable="false"
|
|
:header="editId ? 'Editar documento' : 'Novo documento'"
|
|
:style="{ width: '680px', maxWidth: '95vw' }"
|
|
>
|
|
<!-- Tabs de navegação -->
|
|
<div class="flex gap-0 border-b border-[var(--surface-border)] -mx-6 px-6">
|
|
<button
|
|
v-for="(tab, i) in ['Conteúdo', 'Perguntas FAQ', 'Mídias', 'Configurações']"
|
|
:key="i"
|
|
class="flex items-center px-4 py-2.5 text-[1rem] font-medium bg-transparent border-none border-b-2 -mb-px cursor-pointer transition-colors whitespace-nowrap"
|
|
:class="activeTab === i
|
|
? 'text-[var(--primary-color)] border-b-[var(--primary-color)]'
|
|
: 'text-[var(--text-color-secondary)] border-b-transparent hover:text-[var(--text-color)]'"
|
|
:style="activeTab === i ? 'border-bottom-color: var(--primary-color)' : 'border-bottom-color: transparent'"
|
|
@click="activeTab = i"
|
|
>
|
|
<i class="mr-1.5" :class="['pi', i === 0 ? 'pi-align-left' : i === 1 ? 'pi-comments' : i === 2 ? 'pi-image' : 'pi-cog']" />
|
|
{{ tab }}
|
|
<span v-if="i === 1 && form._faq_itens.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 ml-1.5 rounded-full bg-[var(--primary-color)] text-white text-[1rem] font-bold">{{ form._faq_itens.length }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-5 pt-4">
|
|
|
|
<!-- ── Aba 0: Conteúdo ─────────────────────────────────── -->
|
|
<template v-if="activeTab === 0">
|
|
<div>
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Título *</label>
|
|
<InputText v-model="form.titulo" class="w-full mt-1" placeholder="Ex.: Como fazer login, Como trocar a senha…" />
|
|
</div>
|
|
<div>
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Página de exibição *</label>
|
|
<Select
|
|
v-model="form.pagina_path"
|
|
:options="pageSelectOptions"
|
|
optionLabel="label"
|
|
optionValue="path"
|
|
class="w-full mt-1"
|
|
filter
|
|
filterPlaceholder="Buscar página…"
|
|
placeholder="Selecione a página…"
|
|
>
|
|
<template #option="{ option }">
|
|
<div class="flex items-center justify-between gap-2 w-full min-w-0">
|
|
<span class="truncate text-[1rem]">{{ option.label }}</span>
|
|
<span
|
|
v-if="docsCountByPath.get(option.path)"
|
|
class="shrink-0 inline-flex items-center gap-1 text-[0.6rem] font-bold px-1.5 py-0.5 rounded-full bg-[var(--primary-color)] text-white opacity-80"
|
|
>
|
|
<i class="pi pi-file" style="font-size:0.6rem" />
|
|
{{ docsCountByPath.get(option.path) }} doc{{ docsCountByPath.get(option.path) > 1 ? 's' : '' }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
</Select>
|
|
<InputText v-if="isCustomPath" v-model="form._custom_path" class="w-full mt-2" placeholder="/caminho/da/pagina" />
|
|
<div v-if="resolvedPath" class="text-[1rem] font-mono text-[var(--text-color-secondary)] mt-1 opacity-70">
|
|
path: {{ resolvedPath }}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Conteúdo</label>
|
|
<Editor v-model="form.conteudo" class="mt-1" editorStyle="height: 220px" />
|
|
</div>
|
|
<div>
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Docs relacionados <span class="opacity-50 ml-1">(exibidos no rodapé como "Veja também")</span></label>
|
|
<MultiSelect
|
|
v-model="form.docs_relacionados"
|
|
:options="docsRelOptions"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
class="w-full mt-1"
|
|
filter
|
|
display="chip"
|
|
placeholder="Selecione docs relacionados…"
|
|
:maxSelectedLabels="4"
|
|
emptyFilterMessage="Nenhum doc encontrado"
|
|
emptyMessage="Nenhum doc cadastrado ainda"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ── Aba 1: Perguntas FAQ ────────────────────────────── -->
|
|
<template v-if="activeTab === 1">
|
|
<!-- Habilitar FAQ -->
|
|
<div class="flex items-center justify-between rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3">
|
|
<div>
|
|
<div class="text-[1rem] font-medium">Exibir no portal FAQ</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
|
|
Quando ativo, este documento e suas perguntas aparecem na página de FAQ do sistema.
|
|
</div>
|
|
</div>
|
|
<ToggleSwitch v-model="form.exibir_no_faq" />
|
|
</div>
|
|
|
|
<!-- Categoria -->
|
|
<div>
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Categoria <span class="opacity-50 ml-1">(agrupa no portal FAQ)</span></label>
|
|
<AutoComplete
|
|
v-model="form.categoria"
|
|
:suggestions="categorias.filter(c => !form.categoria || c.toLowerCase().includes(String(form.categoria).toLowerCase()))"
|
|
class="w-full mt-1"
|
|
placeholder="Ex.: Conta, Agenda, Pagamentos…"
|
|
@complete="() => {}"
|
|
/>
|
|
<div v-if="categorias.length" class="flex flex-wrap gap-1 mt-2">
|
|
<button
|
|
v-for="cat in categorias"
|
|
:key="cat"
|
|
class="inline-flex items-center text-[1rem] font-medium px-2 py-0.5 rounded-full border cursor-pointer transition-all"
|
|
:class="form.categoria === cat
|
|
? 'text-[var(--primary-color)] border-[var(--primary-color)]'
|
|
: 'text-[var(--text-color-secondary)] border-[var(--surface-border)] bg-[var(--surface-ground)] hover:border-[var(--primary-color)] hover:text-[var(--primary-color)]'"
|
|
:style="form.categoria === cat ? 'background: color-mix(in srgb, var(--primary-color) 12%, transparent); border-color: color-mix(in srgb, var(--primary-color) 35%, transparent)' : ''"
|
|
@click="form.categoria = form.categoria === cat ? '' : cat"
|
|
>
|
|
{{ cat }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lista de itens FAQ -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Perguntas e respostas</label>
|
|
<Button icon="pi pi-plus" label="Adicionar pergunta" text size="small" @click="addFaqItem" />
|
|
</div>
|
|
|
|
<div v-if="faqLoading" class="flex justify-center py-6">
|
|
<i class="pi pi-spinner pi-spin opacity-40" />
|
|
</div>
|
|
|
|
<div v-else-if="!form._faq_itens.length" class="text-[1rem] text-[var(--text-color-secondary)] italic text-center py-6 border border-dashed border-[var(--surface-border)] rounded-md">
|
|
Nenhuma pergunta adicionada ainda.<br />
|
|
<button class="text-[var(--primary-color)] mt-1 underline text-[1rem]" @click="addFaqItem">Adicionar a primeira</button>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col gap-3">
|
|
<div
|
|
v-for="(item, idx) in form._faq_itens"
|
|
:key="idx"
|
|
class="p-3.5 border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)]"
|
|
>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color-secondary)] opacity-60">Pergunta {{ idx + 1 }}</span>
|
|
<div class="flex items-center gap-1">
|
|
<ToggleSwitch v-model="item.ativo" v-tooltip.top="'Ativo'" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="removeFaqItem(idx)" />
|
|
</div>
|
|
</div>
|
|
<InputText
|
|
v-model="item.pergunta"
|
|
class="w-full mb-2"
|
|
placeholder="Ex.: Como redefinir minha senha?"
|
|
/>
|
|
<Editor
|
|
v-model="item.resposta"
|
|
editorStyle="height: 120px"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ── Aba 2: Mídias ──────────────────────────────────── -->
|
|
<template v-if="activeTab === 2">
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Imagens e vídeos</label>
|
|
<Button icon="pi pi-plus" label="Adicionar" text size="small" @click="addMedia" />
|
|
</div>
|
|
|
|
<div v-if="!form.medias.length" class="text-[1rem] text-[var(--text-color-secondary)] italic">
|
|
Nenhuma mídia adicionada.
|
|
</div>
|
|
|
|
<div v-for="(m, idx) in form.medias" :key="idx" class="flex items-center gap-2 mb-2 p-2 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
|
<Select
|
|
v-model="m.tipo"
|
|
:options="[{ label: 'Imagem', value: 'imagem' }, { label: 'Vídeo (embed)', value: 'video' }]"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
class="w-36 shrink-0"
|
|
/>
|
|
<template v-if="m.tipo === 'imagem'">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
style="display: none"
|
|
:ref="el => { if (el) fileInputRefs[idx] = el }"
|
|
@change="uploadImagem(idx, $event)"
|
|
/>
|
|
<div v-if="!m.url" class="flex-1">
|
|
<Button icon="pi pi-upload" label="Enviar imagem" severity="secondary" outlined :loading="uploadingIdx.has(idx)" class="w-full" @click="triggerUpload(idx)" />
|
|
</div>
|
|
<div v-else class="flex items-center gap-2 flex-1 min-w-0">
|
|
<img :src="m.url" class="w-14 h-14 object-cover rounded-md border border-[var(--surface-border)] flex-shrink-0" :alt="`Imagem ${idx + 1}`" />
|
|
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
|
<span class="text-[1rem] font-mono truncate text-[var(--text-color-secondary)]">{{ m.url }}</span>
|
|
<Button icon="pi pi-refresh" label="Trocar imagem" text size="small" severity="secondary" :loading="uploadingIdx.has(idx)" @click="triggerUpload(idx)" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<InputText v-else v-model="m.url" class="flex-1" placeholder="https://www.youtube.com/embed/ID" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="removeMedia(idx)" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ── Aba 3: Configurações ───────────────────────────── -->
|
|
<template v-if="activeTab === 3">
|
|
<div>
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Visibilidade</label>
|
|
<div class="mt-1">
|
|
<SelectButton v-model="form.tipo_acesso" :options="tipoAcessoOptions" optionLabel="label" optionValue="value" />
|
|
</div>
|
|
</div>
|
|
<div class="flex items-end gap-4">
|
|
<div class="flex-1">
|
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Ordem de exibição</label>
|
|
<InputNumber v-model="form.ordem" class="w-full mt-1" :min="0" />
|
|
</div>
|
|
<div class="flex items-center gap-2 pb-1">
|
|
<ToggleSwitch v-model="form.ativo" inputId="doc-ativo" />
|
|
<label for="doc-ativo" class="text-[1rem] cursor-pointer select-none">Ativo</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
|
|
<Button
|
|
:label="editId ? 'Salvar alterações' : 'Criar documento'"
|
|
icon="pi pi-check"
|
|
:disabled="!formValid"
|
|
:loading="saving"
|
|
@click="salvar"
|
|
/>
|
|
</template>
|
|
</Dialog>
|
|
<!-- ══ Dialog de Prompt IA ════════════════════════════════ -->
|
|
<Dialog
|
|
v-model:visible="promptDlgOpen"
|
|
modal
|
|
:draggable="false"
|
|
header="Gerar documentação com IA"
|
|
:style="{ width: '680px', maxWidth: '95vw' }"
|
|
pt:mask:class="backdrop-blur-xs"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
|
|
<!-- Instrução humana -->
|
|
<div class="flex items-start gap-3 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
|
<i class="pi pi-info-circle text-blue-500 text-lg shrink-0 mt-0.5" />
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
|
Usando o <strong class="text-[var(--text-color)]">Claude</strong>, envie o arquivo <code class="text-[1rem] bg-[var(--surface-border)] px-1.5 py-0.5 rounded">.vue</code> da página que deseja documentar.
|
|
O Claude irá analisar os componentes, identificar os <code class="text-[1rem] bg-[var(--surface-border)] px-1.5 py-0.5 rounded">id</code>s dos elementos,
|
|
reproduzir visualmente os cards e seções dentro do conteúdo, criar as perguntas FAQ, definir a rota correta de exibição
|
|
e retornar um arquivo <code class="text-[1rem] bg-[var(--surface-border)] px-1.5 py-0.5 rounded">.json</code> pronto para importar aqui.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Prompt copiável -->
|
|
<div class="relative">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60">
|
|
<i class="pi pi-clipboard mr-1" />Prompt para o Claude
|
|
</span>
|
|
<Button
|
|
icon="pi pi-copy"
|
|
label="Copiar"
|
|
size="small"
|
|
text
|
|
class="rounded-full"
|
|
@click="copiarPrompt"
|
|
/>
|
|
</div>
|
|
<pre class="prompt-pre">Você é um especialista em documentação de produto SaaS voltado para psicólogos e terapeutas.
|
|
|
|
Vou te enviar o código-fonte de uma página Vue.js do sistema. Sua tarefa é:
|
|
|
|
1. ANALISAR o código completo — componentes, props, lógica, rotas, dados carregados do Supabase e regras de negócio visíveis
|
|
2. IDENTIFICAR todos os elementos com id= e quais não têm (sugerindo os que deveriam ter para o sistema de highlight da ajuda)
|
|
3. REPRODUZIR visualmente no campo "conteudo" os cards, timelines, tabelas e seções mais importantes usando HTML inline com estilos CSS, exatamente como foi feito nos exemplos abaixo — para que o usuário veja o elemento real dentro da documentação
|
|
4. CRIAR links de highlight usando o padrão: <a data-highlight="id-do-elemento" data-route="/rota-da-pagina" href="#">Ver na tela</a>
|
|
5. DEFINIR a rota correta no campo "pagina_path" baseada nos comentários, nome do componente e router-links encontrados no código
|
|
6. GERAR entre 8 e 12 perguntas FAQ reais que um terapeuta iniciante faria sobre essa tela
|
|
7. RETORNAR um único arquivo .json com a estrutura abaixo, pronto para importar no sistema
|
|
|
|
ESTRUTURA DO JSON (siga exatamente):
|
|
{
|
|
"titulo": "string — título claro e objetivo",
|
|
"conteudo": "string — HTML rico com <h2>, <h3>, <h4>, <p>, <ul>, <li>, <strong>, cards reproduzidos em HTML inline e links data-highlight",
|
|
"categoria": "string — ex: Agenda, Pacientes, Financeiro, Configurações",
|
|
"exibir_no_faq": true,
|
|
"tipo_acesso": "usuario",
|
|
"pagina_path": "string — rota exata ex: /therapist, /therapist/agenda",
|
|
"ordem": 1,
|
|
"ativo": true,
|
|
"medias": [{ "tipo": "imagem", "url": "" }],
|
|
"_faq_itens": [
|
|
{
|
|
"pergunta": "string — pergunta real de um usuário iniciante",
|
|
"resposta": "string — resposta clara e direta",
|
|
"ordem": 0,
|
|
"ativo": true
|
|
}
|
|
]
|
|
}
|
|
|
|
REGRAS OBRIGATÓRIAS:
|
|
- "conteudo" deve ser HTML válido em português do Brasil
|
|
- Reproduza visualmente pelo menos 2 elementos da tela (cards, timeline, etc.) usando HTML+CSS inline
|
|
- Explique TODOS os botões, campos, filtros, modais e fluxos visíveis no código
|
|
- Se houver regras de negócio (ex: item padrão não pode ser excluído, campo obrigatório), documente
|
|
- Para cada elemento importante que tiver id= no código, crie um link data-highlight correspondente
|
|
- Se um elemento importante NÃO tiver id=, mencione que o id precisa ser adicionado ao componente
|
|
- Links data-highlight funcionam tanto no "conteudo" quanto nas "respostas" do _faq_itens — use em ambos sempre que referenciar um elemento da tela
|
|
- Nas respostas do FAQ, quando mencionar um card ou seção específica, sempre inclua o link data-highlight correspondente e, se houver uma rota dedicada para aquela funcionalidade (ex: $router.push encontrado no código), inclua também o caminho em <code> ou como texto
|
|
- "medias" deve ter 1 item vazio como placeholder
|
|
- Não invente funcionalidades que não existem no código
|
|
|
|
Agora analise o arquivo .vue anexo e gere o JSON completo:</pre>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
|
<i class="pi pi-lightbulb mr-1" />Cole o prompt no Claude e anexe o arquivo .vue da página
|
|
</span>
|
|
<div class="flex gap-2">
|
|
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="promptDlgOpen = false" />
|
|
<Button label="Copiar prompt" icon="pi pi-copy" class="rounded-full" @click="copiarPrompt" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
|
|
</template>
|
|
|
|
<style scoped>
|
|
.prompt-pre {
|
|
font-family: var(--font-family, monospace);
|
|
font-size: 0.75rem;
|
|
line-height: 1.65;
|
|
color: var(--text-color);
|
|
background: var(--surface-ground);
|
|
border: 1px solid var(--surface-border);
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 380px;
|
|
overflow-y: auto;
|
|
margin: 0;
|
|
}
|
|
|
|
:deep(.row-atencao td) {
|
|
background: color-mix(in srgb, #ef4444 4%, var(--surface-card)) !important;
|
|
}
|
|
:deep(.row-atencao:hover td) {
|
|
background: color-mix(in srgb, #ef4444 8%, var(--surface-card)) !important;
|
|
}
|
|
</style> |