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,377 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/documents/DocumentsListPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Menu from 'primevue/menu'
import { useDocuments } from './composables/useDocuments'
import DocumentCard from './components/DocumentCard.vue'
import DocumentUploadDialog from './components/DocumentUploadDialog.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
import DocumentGenerateDialog from './components/DocumentGenerateDialog.vue'
import DocumentSignatureDialog from './components/DocumentSignatureDialog.vue'
import DocumentShareDialog from './components/DocumentShareDialog.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
// ── Props (pode receber patientId via route ou prop) ────────
const props = defineProps({
patientId: { type: String, default: null },
patientName: { type: String, default: '' },
embedded: { type: Boolean, default: false }
})
const resolvedPatientId = computed(() => props.patientId || route.params.id || null)
const {
documents, loading, error, filters, usedTags, stats,
TIPOS_DOCUMENTO,
fetchDocuments, upload, update, remove, restore,
download, getPreviewUrl, fetchUsedTags, clearFilters,
formatSize, mimeIcon
} = useDocuments(() => resolvedPatientId.value)
// ── Dialogs ─────────────────────────────────────────────────
const uploadDlg = ref(false)
const previewDlg = ref(false)
const generateDlg = ref(false)
const signatureDlg = ref(false)
const shareDlg = ref(false)
const selectedDoc = ref(null)
const previewUrl = ref('')
// ── Mobile menu ─────────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
{ label: 'Upload', icon: 'pi pi-upload', command: () => uploadDlg.value = true },
{ label: 'Gerar documento', icon: 'pi pi-file-pdf', command: () => generateDlg.value = true },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchDocuments() }
])
// ── Hero sticky ─────────────────────────────────────────────
const headerEl = ref(null)
const headerStuck = ref(false)
// ── Lifecycle ───────────────────────────────────────────────
onMounted(async () => {
await Promise.all([fetchDocuments(), fetchUsedTags()])
})
// ── Acoes ───────────────────────────────────────────────────
async function onUploaded({ file, meta }) {
try {
await upload(file, resolvedPatientId.value, meta)
toast.add({ severity: 'success', summary: 'Enviado', detail: file.name, life: 3000 })
fetchUsedTags()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message })
}
}
async function onPreview(doc) {
selectedDoc.value = doc
try {
previewUrl.value = await getPreviewUrl(doc)
} catch {
previewUrl.value = ''
}
previewDlg.value = true
}
function onDownload(doc) {
download(doc)
}
function onEdit(doc) {
selectedDoc.value = doc
// TODO: abrir dialog de edicao de metadados
toast.add({ severity: 'info', summary: 'Em breve', detail: 'Edição de metadados será implementada.', life: 2000 })
}
function onDelete(doc) {
confirm.require({
message: `Excluir "${doc.nome_original}"? O arquivo será retido por 5 anos conforme LGPD/CFP.`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await remove(doc.id)
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 2000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
}
}
})
}
function onShare(doc) {
selectedDoc.value = doc
shareDlg.value = true
}
function onSign(doc) {
selectedDoc.value = doc
signatureDlg.value = true
}
function onGenerated() {
fetchDocuments()
}
// ── Computed: filtro ativo ───────────────────────────────────
const hasActiveFilter = computed(() =>
filters.value.tipo_documento || filters.value.tag || filters.value.search
)
// ── Watch filtros ───────────────────────────────────────────
watch(filters, () => fetchDocuments(), { deep: true })
</script>
<template>
<div :class="embedded ? '' : 'px-4 py-6 max-w-[1200px] mx-auto'">
<!-- Hero header -->
<div
v-if="!embedded"
ref="headerEl"
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6"
>
<div>
<h1 class="text-xl font-bold">Documentos</h1>
<p class="text-sm text-[var(--text-color-secondary)]">
{{ resolvedPatientId ? patientName || 'Paciente' : 'Todos os pacientes' }}
</p>
</div>
<!-- Desktop actions -->
<div class="hidden sm:flex items-center gap-2">
<Button
label="Gerar documento"
icon="pi pi-file-pdf"
outlined
size="small"
@click="generateDlg = true"
:disabled="!resolvedPatientId"
/>
<Button
label="Upload"
icon="pi pi-upload"
size="small"
@click="uploadDlg = true"
:disabled="!resolvedPatientId"
/>
</div>
<!-- Mobile menu -->
<div class="sm:hidden">
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Embedded header (dentro do prontuario) -->
<div v-else class="flex items-center justify-between gap-2 mb-4">
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
<div class="flex gap-1.5">
<Button
icon="pi pi-file-pdf"
text
rounded
size="small"
v-tooltip.top="'Gerar documento'"
@click="generateDlg = true"
/>
<Button
icon="pi pi-upload"
text
rounded
size="small"
v-tooltip.top="'Upload'"
@click="uploadDlg = true"
/>
</div>
</div>
<!-- Quick stats -->
<div v-if="!embedded && documents.length" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ stats.total }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Total</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ formatSize(stats.tamanhoTotal) }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tamanho</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ Object.keys(stats.porTipo).length }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tipos</span>
</div>
<div v-if="stats.pendentesRevisao" class="flex flex-col items-center p-3 rounded-lg bg-amber-500/5 border border-amber-500/20">
<span class="text-lg font-bold text-amber-600">{{ stats.pendentesRevisao }}</span>
<span class="text-[0.65rem] text-amber-600 uppercase tracking-wider">Pendentes</span>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filters.search"
placeholder="Buscar..."
class="!w-[200px]"
size="small"
/>
</IconField>
<Select
v-model="filters.tipo_documento"
:options="TIPOS_DOCUMENTO"
optionLabel="label"
optionValue="value"
placeholder="Tipo"
showClear
class="!w-[160px]"
size="small"
/>
<Select
v-if="usedTags.length"
v-model="filters.tag"
:options="usedTags.map(t => ({ label: t, value: t }))"
optionLabel="label"
optionValue="value"
placeholder="Tag"
showClear
class="!w-[140px]"
size="small"
/>
<Button
v-if="hasActiveFilter"
icon="pi pi-filter-slash"
text
rounded
size="small"
v-tooltip.top="'Limpar filtros'"
@click="clearFilters(); fetchDocuments()"
/>
</div>
<!-- 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="!documents.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
<i class="pi pi-inbox text-4xl opacity-30 mb-3" />
<div class="text-sm mb-1">
{{ hasActiveFilter ? 'Nenhum documento encontrado com esses filtros.' : 'Nenhum documento ainda.' }}
</div>
<Button
v-if="resolvedPatientId && !hasActiveFilter"
label="Enviar primeiro documento"
icon="pi pi-upload"
text
size="small"
class="mt-2"
@click="uploadDlg = true"
/>
</div>
<!-- Lista de documentos -->
<div v-else class="flex flex-col gap-2">
<DocumentCard
v-for="doc in documents"
:key="doc.id"
:doc="doc"
@preview="onPreview"
@download="onDownload"
@edit="onEdit"
@delete="onDelete"
@share="onShare"
@sign="onSign"
/>
</div>
<!-- Error -->
<div v-if="error" class="mt-4 p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
</div>
<!-- Dialogs -->
<DocumentUploadDialog
:visible="uploadDlg"
@update:visible="uploadDlg = $event"
:patientId="resolvedPatientId"
:patientName="patientName"
:usedTags="usedTags"
@uploaded="onUploaded"
/>
<DocumentPreviewDialog
:visible="previewDlg"
@update:visible="previewDlg = $event"
:doc="selectedDoc"
:previewUrl="previewUrl"
@download="onDownload"
@edit="onEdit"
@delete="d => { previewDlg = false; onDelete(d) }"
@share="d => { previewDlg = false; onShare(d) }"
@sign="d => { previewDlg = false; onSign(d) }"
/>
<DocumentGenerateDialog
:visible="generateDlg"
@update:visible="generateDlg = $event"
:patientId="resolvedPatientId"
:patientName="patientName"
@generated="onGenerated"
/>
<DocumentSignatureDialog
:visible="signatureDlg"
@update:visible="signatureDlg = $event"
:doc="selectedDoc"
/>
<DocumentShareDialog
:visible="shareDlg"
@update:visible="shareDlg = $event"
:doc="selectedDoc"
/>
<ConfirmDialog />
</div>
</template>