Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions
@@ -0,0 +1,297 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/DocumentTemplatesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Menu from 'primevue/menu'
import { useDocumentTemplates } from './composables/useDocumentTemplates'
import DocumentTemplateEditor from './components/DocumentTemplateEditor.vue'
const toast = useToast()
const confirm = useConfirm()
const {
templates, loading, error,
globalTemplates, tenantTemplates,
TIPOS_TEMPLATE,
fetchTemplates, create, update, remove, duplicate
} = useDocumentTemplates()
// ── Views ───────────────────────────────────────────────────
const view = ref('list') // list | create | edit
const editingTemplate = ref({})
const editingId = ref(null)
// ── Mobile menu ─────────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = [
{ label: 'Novo template', icon: 'pi pi-plus', command: () => openCreate() },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchTemplates(true) }
]
// ── Lifecycle ───────────────────────────────────────────────
onMounted(() => fetchTemplates(true))
// ── Acoes ───────────────────────────────────────────────────
function openCreate() {
editingId.value = null
editingTemplate.value = {}
view.value = 'create'
}
function openEdit(tpl) {
if (tpl.is_global) {
toast.add({ severity: 'warn', summary: 'Somente leitura', detail: 'Templates padrão não podem ser editados. Duplique para personalizar.', life: 3000 })
return
}
editingId.value = tpl.id
editingTemplate.value = { ...tpl }
view.value = 'edit'
}
async function onSave(payload) {
try {
if (view.value === 'create') {
await create(payload)
toast.add({ severity: 'success', summary: 'Criado', detail: payload.nome_template, life: 3000 })
} else {
await update(editingId.value, payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: payload.nome_template, life: 3000 })
}
view.value = 'list'
fetchTemplates(true)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
function onDuplicate(tpl) {
confirm.require({
message: `Deseja copiar "${tpl.nome_template}" para os seus templates? Você poderá editá-lo livremente.`,
header: 'Duplicar template',
icon: 'pi pi-copy',
acceptLabel: 'Copiar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await duplicate(tpl.id)
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Meus Templates.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
})
}
function onDelete(tpl) {
confirm.require({
message: `Desativar template "${tpl.nome_template}"?`,
header: 'Confirmar',
icon: 'pi pi-exclamation-triangle',
accept: async () => {
try {
await remove(tpl.id)
toast.add({ severity: 'success', summary: 'Desativado', life: 2000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
})
}
function onCancel() {
view.value = 'list'
}
// ── Tipo label ──────────────────────────────────────────────
function tipoLabel(tipo) {
return TIPOS_TEMPLATE.find(t => t.value === tipo)?.label || tipo
}
// ── Template card menu ──────────────────────────────────────
function getCardMenuItems(tpl) {
const items = [
{ label: 'Duplicar', icon: 'pi pi-copy', command: () => onDuplicate(tpl) }
]
if (!tpl.is_global) {
items.push(
{ label: 'Editar', icon: 'pi pi-pencil', command: () => openEdit(tpl) },
{ separator: true },
{ label: 'Desativar', icon: 'pi pi-trash', class: 'text-red-500', command: () => onDelete(tpl) }
)
}
return items
}
</script>
<template>
<div class="px-4 py-6 max-w-[1200px] mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<div class="flex items-center gap-2">
<Button
v-if="view !== 'list'"
icon="pi pi-arrow-left"
text
rounded
size="small"
@click="view = 'list'"
/>
<h1 class="text-xl font-bold">
<template v-if="view === 'list'">Templates de documentos</template>
<template v-else-if="view === 'create'">Novo template</template>
<template v-else>Editar template</template>
</h1>
</div>
<p v-if="view === 'list'" class="text-sm text-[var(--text-color-secondary)]">
Modelos para declarações, atestados, recibos e outros documentos
</p>
</div>
<div v-if="view === 'list'" class="hidden sm:flex items-center gap-2">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
</div>
<div v-if="view === 'list'" class="sm:hidden">
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- List view -->
<template v-if="view === 'list'">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!templates.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
<i class="pi pi-file-edit text-4xl opacity-30 mb-3" />
<div class="text-sm mb-1">Nenhum template encontrado.</div>
<Button label="Criar primeiro template" icon="pi pi-plus" text size="small" class="mt-2" @click="openCreate" />
</div>
<template v-else>
<!-- Templates globais (padrao) -->
<div v-if="globalTemplates.length" class="mb-6">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
Templates padrão do sistema
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="tpl in globalTemplates"
:key="tpl.id"
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)] transition-all cursor-pointer"
@click="onDuplicate(tpl)"
>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center">
<i class="pi pi-file text-blue-500" />
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
</div>
</div>
<span class="absolute top-2 right-2 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">
padrão
</span>
<div class="mt-2 text-[0.65rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
Clique para duplicar e personalizar
</div>
</div>
</div>
</div>
<!-- Templates do tenant -->
<div v-if="tenantTemplates.length">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
Meus templates
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="tpl in tenantTemplates"
:key="tpl.id"
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 transition-all cursor-pointer"
@click="openEdit(tpl)"
>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="pi pi-file-edit text-primary" />
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
</div>
</div>
<!-- Menu de acoes -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
icon="pi pi-ellipsis-v"
text
rounded
size="small"
class="!w-7 !h-7"
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
/>
<Menu :ref="`menu_${tpl.id}`" :model="getCardMenuItems(tpl)" :popup="true" />
</div>
<div class="flex items-center gap-2 mt-2">
<span
v-if="!tpl.ativo"
class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500"
>
inativo
</span>
<span class="text-[0.6rem] text-[var(--text-color-secondary)]">
{{ tpl.variaveis?.length || 0 }} variáveis
</span>
</div>
</div>
</div>
</div>
</template>
</template>
<!-- Create / Edit view -->
<template v-if="view === 'create' || view === 'edit'">
<DocumentTemplateEditor
v-model="editingTemplate"
:mode="view"
@save="onSave"
@cancel="onCancel"
/>
</template>
<ConfirmDialog />
</div>
</template>