1112 lines
59 KiB
Vue
1112 lines
59 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/views/pages/saas/SaasDocsPage.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<!-- 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>
|
|
<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 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)] 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>
|