Files
agenciapsilmno/src/views/pages/saas/SaasDocsPage.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 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: &lt;a data-highlight="id-do-elemento" data-route="/rota-da-pagina" href="#"&gt;Ver na tela&lt;/a&gt;
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 &lt;h2&gt;, &lt;h3&gt;, &lt;h4&gt;, &lt;p&gt;, &lt;ul&gt;, &lt;li&gt;, &lt;strong&gt;, 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 &lt;code&gt; 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>