Files
agenciapsilmno/src/features/documents/DocumentsListPage.vue
T
Leonardo 957e912a7f Melissa polish + Prontuario Visao Geral + agenda historico
Sprints B (05-03) e C (05-04) acumulados:

- NotificationDrawer/Item redesign (visual mais limpo, ações inline)
- Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore)
- MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico
  card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado
- useFeriados: cache opt-in pra evitar fetch redundante de feriados
- PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish
- AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes
  de paridade com Melissa
- DocumentsListPage: pequenos ajustes
- DB migration 20260504000001: fix do trigger pra status 'excluido' nas
  cancel_notifications

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:11:55 -03:00

426 lines
22 KiB
Vue

<!--
|--------------------------------------------------------------------------
| 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, onBeforeUnmount, 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 headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile dialogs ──────────────────────────────────────────
const filtersDlgOpen = ref(false)
// ── Lifecycle ───────────────────────────────────────────────
onMounted(async () => {
if (!props.embedded) {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
}
await Promise.all([fetchDocuments(), fetchUsedTags()])
})
onBeforeUnmount(() => {
_observer?.disconnect()
})
// ── 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>
<!--
EMBEDDED MODE dentro do prontuário (sem hero, layout compacto)
-->
<div v-if="embedded">
<!-- Header compacto: ações alinhadas à direita, sem label -->
<div class="flex items-center justify-end gap-2 mb-4">
<Button label="Upload" icon="pi pi-upload" size="small" outlined class="rounded-full" @click="uploadDlg = true" />
<Button label="Template" icon="pi pi-file-pdf" size="small" class="rounded-full" @click="generateDlg = true" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="empty-rich">
<div class="empty-rich__icon"><i class="pi pi-folder-open" /></div>
<div class="empty-rich__title">Nenhum documento ainda</div>
<div class="empty-rich__sub">Faça upload do primeiro laudo, receita, exame ou termo assinado deste paciente.</div>
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
</div>
<!-- Lista -->
<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-md bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
</div>
</div>
<!--
PÁGINA FULL hero sticky + stats + lista em card
-->
<template v-else>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="headerEl"
class="sticky my-3 md:mx-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/9" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Voltar + Brand -->
<div class="flex items-center gap-2 shrink-0">
<Button v-if="resolvedPatientId" icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar'" @click="router.back()" />
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-file text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Documentos</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">
{{ resolvedPatientId ? (patientName || 'Paciente') : 'Todos os pacientes' }}
</div>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Atualizar'" @click="fetchDocuments" />
<Button label="Gerar" icon="pi pi-file-pdf" severity="secondary" outlined size="small" class="rounded-full" :disabled="!resolvedPatientId" @click="generateDlg = true" />
<Button label="Upload" icon="pi pi-upload" size="small" class="rounded-full" :disabled="!resolvedPatientId" @click="uploadDlg = true" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-filter" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Filtros'" @click="filtersDlgOpen = true" />
<Button icon="pi pi-upload" class="h-9 w-9 rounded-full" :disabled="!resolvedPatientId" @click="uploadDlg = true" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Dialog filtros mobile -->
<Dialog v-model:visible="filtersDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Filtros" class="w-[94vw] max-w-sm">
<div class="flex flex-col gap-3 pt-1">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters.search" placeholder="Buscar..." class="w-full" />
</IconField>
<Select v-model="filters.tipo_documento" :options="TIPOS_DOCUMENTO" optionLabel="label" optionValue="value" placeholder="Tipo" showClear class="w-full" />
<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-full" />
</div>
<template #footer>
<Button v-if="hasActiveFilter" label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined class="rounded-full mr-2" @click="clearFilters(); fetchDocuments()" />
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="filtersDlgOpen = false" />
</template>
</Dialog>
<!-- Conteúdo -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3 xl:gap-4">
<!-- Stats -->
<div v-if="documents.length" class="grid grid-cols-2 md:grid-cols-4 gap-2">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ stats.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Total</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ formatSize(stats.tamanhoTotal) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Tamanho</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ Object.keys(stats.porTipo).length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Tipos</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border" :class="stats.pendentesRevisao ? 'border-amber-500/30 bg-amber-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'">
<div class="text-[1.35rem] font-bold leading-none" :class="stats.pendentesRevisao ? 'text-amber-600' : 'text-[var(--text-color)]'">{{ stats.pendentesRevisao || 0 }}</div>
<div class="text-[1rem] opacity-75 truncate" :class="stats.pendentesRevisao ? 'text-amber-600' : 'text-[var(--text-color-secondary)]'">Pendentes</div>
</div>
</div>
<!-- Tabela (card) -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Header da tabela -->
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-list text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Documentos</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.72rem] font-bold">{{ documents.length }}</span>
</div>
<!-- Filtros desktop -->
<div class="hidden md:flex flex-wrap items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50">
<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" severity="danger" 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="empty-rich m-4">
<div class="empty-rich__icon">
<i :class="hasActiveFilter ? 'pi pi-filter-slash' : 'pi pi-folder-open'" />
</div>
<div class="empty-rich__title">
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
</div>
<div class="empty-rich__sub">
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca pra ver outros resultados.' : resolvedPatientId ? 'Faça upload do primeiro laudo, receita, exame ou termo assinado deste paciente.' : 'Selecione um paciente para adicionar documentos.' }}
</div>
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2 p-3">
<DocumentCard v-for="doc in documents" :key="doc.id" :doc="doc" @preview="onPreview" @download="onDownload" @edit="onEdit" @delete="onDelete" @share="onShare" @sign="onSign" />
</div>
</div>
<!-- Error -->
<div v-if="error" class="p-3 rounded-md bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
</div>
</div>
</template>
<!--
Dialogs comuns (usados em ambos os modos)
-->
<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 />
</template>
<style scoped>
/* Empty state rico — espelha .pp-empty--rich do PatientProntuario.vue.
Padroniza visual em ambos os modos (embedded e standalone). */
.empty-rich {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
padding: 48px 24px;
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
border-radius: 16px;
background:
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
var(--surface-card);
color: var(--text-color-secondary);
text-align: center;
}
.empty-rich__icon {
width: 72px;
height: 72px;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
color: var(--primary-color);
margin-bottom: 4px;
}
.empty-rich__icon .pi { font-size: 2rem; }
.empty-rich__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.02em;
}
.empty-rich__sub {
font-size: 0.82rem;
color: var(--text-color-secondary);
max-width: 340px;
line-height: 1.5;
}
.empty-rich__cta {
margin-top: 6px;
}
</style>