Files
agenciapsilmno/src/views/pages/saas/SaasDocsPage.vue
2026-03-17 21:08:14 -03:00

1227 lines
54 KiB
Vue

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