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:
@@ -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>
|
||||
Reference in New Issue
Block a user