melissa/paciente: aba Documentos vira pagina nativa 2-col

Antes: <DocumentsListPage embedded /> reusava o componente do
Rail/Classic em modo embed — visual conflitava com o padrao
Melissa, sem agrupamento por tipo, scroll inconsistente.

Novo: MelissaPatientDocuments.vue (componente nativo 2-col
seguindo MelissaDocumentosTemplates):
- Sidebar esquerda: tipos de documento com contadores
  (Todos, Laudo, Receita, Exame, Termo assinado, Relatorio
  externo, Identidade, Convenio, Declaracao, Atestado,
  Recibo, Outro). Item ativo destaca primary; vazios em
  opacity 50%.
- Main direita: header com titulo do tipo + count, DataView
  com cards (DocumentCard reusado), paginacao automatica >12,
  empty states distintos (global vs filtrado).
- Header da pagina: botoes Refresh / Gerar / Upload (primary
  outlined no dark-friendly).
- Mobile <1024px: sidebar vira drawer com botao "Tipos" no
  header (espelha padrao MelissaBloqueios/Templates).

Reaproveita do features/documents:
- useDocuments composable
- DocumentCard, DocumentUploadDialog, DocumentPreviewDialog,
  DocumentGenerateDialog, DocumentSignatureDialog,
  DocumentShareDialog

MelissaPaciente.vue: import DocumentsListPage -> Melissa
PatientDocuments + uso na aba.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 21:44:44 -03:00
parent 682840f355
commit 51c33e73b9
2 changed files with 705 additions and 11 deletions
+3 -8
View File
@@ -24,7 +24,7 @@ import { useToast } from 'primevue/usetoast';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import MelissaPatientDocuments from '@/layout/melissa/MelissaPatientDocuments.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
@@ -2092,16 +2092,11 @@ onBeforeUnmount(() => {
</article>
</div>
<!-- DocumentsListPage embedded (ja vem com upload/preview/lista) -->
<section class="mpa-w mpa-embed">
<div class="mpa-w__body mpa-embed__body">
<DocumentsListPage
<!-- Documentos nativos Melissa (2-col com tipos na sidebar) -->
<MelissaPatientDocuments
:patient-id="patientId"
:patient-name="nomeCompleto || ''"
embedded
/>
</div>
</section>
</template>
</div>
@@ -0,0 +1,699 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI MelissaPatientDocuments
|--------------------------------------------------------------------------
| Página nativa Melissa pra aba "Documentos" do prontuário (substitui o
| embed do DocumentsListPage).
|
| Layout 2-col (espelha MelissaDocumentosTemplates):
| - COL 1 (esquerda): tipos de documento como sidebar com contadores
| - COL 2 (direita): DataView dos documentos do tipo selecionado,
| com upload/preview/edit/sign/share/delete via dialogs reaproveitados
| - Mobile (<1024px): sidebar vira drawer (botão "Tipos" no header)
|
| Reaproveita do feature/documents:
| - useDocuments composable (fetch + CRUD + URLs)
| - DocumentCard pra item visual
| - DocumentUploadDialog / PreviewDialog / GenerateDialog /
| SignatureDialog / ShareDialog
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Menu from 'primevue/menu';
import DataView from 'primevue/dataview';
import { useDocuments } from '@/features/documents/composables/useDocuments';
import DocumentCard from '@/features/documents/components/DocumentCard.vue';
import DocumentUploadDialog from '@/features/documents/components/DocumentUploadDialog.vue';
import DocumentPreviewDialog from '@/features/documents/components/DocumentPreviewDialog.vue';
import DocumentGenerateDialog from '@/features/documents/components/DocumentGenerateDialog.vue';
import DocumentSignatureDialog from '@/features/documents/components/DocumentSignatureDialog.vue';
import DocumentShareDialog from '@/features/documents/components/DocumentShareDialog.vue';
const props = defineProps({
patientId: { type: String, default: null },
patientName: { type: String, default: '' }
});
const toast = useToast();
const confirm = useConfirm();
// ── useDocuments composable ───────────────────────────────
const {
documents, loading, error, filters, usedTags, stats,
TIPOS_DOCUMENTO,
fetchDocuments, upload, update, remove,
download, getPreviewUrl, fetchUsedTags
} = useDocuments(() => props.patientId);
// ── 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('');
// ── Tipo selecionado (filtro pela sidebar) ────────────────
// null = todos os tipos
const selectedTipo = ref(null);
const docsByTipo = computed(() => {
const groups = {};
for (const d of documents.value) {
const tipo = d.tipo_documento || 'outro';
if (!groups[tipo]) groups[tipo] = [];
groups[tipo].push(d);
}
return groups;
});
const tipoCounts = computed(() => {
const counts = {};
TIPOS_DOCUMENTO.forEach(t => { counts[t.value] = (docsByTipo.value[t.value] || []).length; });
return counts;
});
const filteredDocs = computed(() => {
if (!selectedTipo.value) return documents.value;
return docsByTipo.value[selectedTipo.value] || [];
});
function tipoLabel(tipo) {
return TIPOS_DOCUMENTO.find(t => t.value === tipo)?.label || tipo;
}
// ── Mobile drawer ─────────────────────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
function selectTipo(tipo) {
selectedTipo.value = tipo;
if (isMobile.value) drawerOpen.value = false;
}
// ── Actions ───────────────────────────────────────────────
async function onUploaded({ file, meta }) {
try {
await upload(file, props.patientId, 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;
// Reusa preview dialog em modo "ver" — edit completo só via DocumentsListPage
onPreview(doc);
}
function onDelete(doc) {
confirm.require({
header: 'Excluir documento',
message: `Excluir "${doc.nome_original}"? Esta ação pode ser revertida no Lixo.`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
await remove(doc.id);
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao excluir', detail: e?.message });
}
}
});
}
function onShare(doc) {
selectedDoc.value = doc;
shareDlg.value = true;
}
function onSign(doc) {
selectedDoc.value = doc;
signatureDlg.value = true;
}
function onGenerated() {
fetchDocuments();
fetchUsedTags();
}
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
await Promise.all([fetchDocuments(), fetchUsedTags()]);
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<!-- Mobile drawer (tipos) -->
<Transition name="mpd-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpd-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mpd-mobile-drawer-target" class="mpd-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mpd-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpd-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mpd-page">
<!-- HEADER -->
<header class="mpd-page__head">
<button
class="mpd-menu-btn mpd-menu-btn--mobile-only"
v-tooltip.bottom="'Tipos de documento'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Tipos</span>
</button>
<div class="mpd-page__title">
<i class="pi pi-file mpd-page__title-icon" />
<span>Documentos</span>
<span class="mpd-page__count">{{ documents.length }}</span>
</div>
<div class="mpd-page__actions">
<button
class="mpd-act-btn"
v-tooltip.bottom="'Atualizar'"
:disabled="loading"
@click="fetchDocuments"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button
class="mpd-act-btn"
v-tooltip.bottom="'Gerar a partir de template'"
:disabled="!patientId"
@click="generateDlg = true"
>
<i class="pi pi-file-pdf" />
<span>Gerar</span>
</button>
<button
class="mpd-act-btn mpd-act-btn--primary"
v-tooltip.bottom="'Enviar arquivo'"
:disabled="!patientId"
@click="uploadDlg = true"
>
<i class="pi pi-upload" />
<span>Upload</span>
</button>
</div>
</header>
<!-- BODY 2-col -->
<div class="mpd-body">
<!-- COL 1 Sidebar de tipos (teleporta no mobile) -->
<Teleport to="#mpd-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpd-side">
<div class="mpd-side__head">
<i class="pi pi-folder" />
<span>Tipos</span>
</div>
<ul class="mpd-side__list">
<li
class="mpd-side__item"
:class="{ 'is-active': selectedTipo === null }"
role="button"
tabindex="0"
@click="selectTipo(null)"
@keydown.enter.prevent="selectTipo(null)"
>
<span class="mpd-side__item-name">Todos</span>
<span class="mpd-side__item-count">{{ documents.length }}</span>
</li>
<li
v-for="t in TIPOS_DOCUMENTO"
:key="t.value"
class="mpd-side__item"
:class="{ 'is-active': selectedTipo === t.value, 'is-empty': tipoCounts[t.value] === 0 }"
role="button"
tabindex="0"
@click="selectTipo(t.value)"
@keydown.enter.prevent="selectTipo(t.value)"
>
<span class="mpd-side__item-name">{{ t.label }}</span>
<span class="mpd-side__item-count">{{ tipoCounts[t.value] || 0 }}</span>
</li>
</ul>
</aside>
</Teleport>
<!-- COL 2 Main: lista de documentos -->
<main class="mpd-main">
<header class="mpd-main__head">
<div class="mpd-main__title-row">
<div class="mpd-main__title">
<i class="pi pi-file" />
<span>{{ selectedTipo ? tipoLabel(selectedTipo) : 'Todos os documentos' }}</span>
</div>
<span class="mpd-page__count">{{ filteredDocs.length }}</span>
</div>
<p class="mpd-main__subtitle">
<template v-if="selectedTipo">
Documentos do tipo <strong>{{ tipoLabel(selectedTipo) }}</strong> deste paciente.
</template>
<template v-else>
Todos os documentos clínicos vinculados a este paciente.
</template>
</p>
</header>
<!-- Loading -->
<div v-if="loading && !documents.length" class="mpd-loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando documentos</span>
</div>
<!-- Empty global -->
<div v-else-if="!documents.length" class="mpd-empty">
<i class="pi pi-folder-open mpd-empty__icon" />
<div class="mpd-empty__title">Nenhum documento ainda</div>
<div class="mpd-empty__hint">
Faça upload de um arquivo ou gere um documento a partir de template.
</div>
<div class="mpd-empty__actions">
<button class="mpd-act-btn" @click="generateDlg = true">
<i class="pi pi-file-pdf" /><span>Gerar template</span>
</button>
<button class="mpd-act-btn mpd-act-btn--primary" @click="uploadDlg = true">
<i class="pi pi-upload" /><span>Upload</span>
</button>
</div>
</div>
<!-- Empty (filtrado) -->
<div v-else-if="!filteredDocs.length" class="mpd-empty">
<i class="pi pi-filter mpd-empty__icon" />
<div class="mpd-empty__title">Nenhum {{ tipoLabel(selectedTipo).toLowerCase() }} ainda</div>
<div class="mpd-empty__hint">
Outros tipos têm documentos
<button class="mpd-link" @click="selectTipo(null)">ver todos</button>.
</div>
</div>
<!-- DataView -->
<DataView
v-else
:value="filteredDocs"
layout="grid"
:paginator="filteredDocs.length > 12"
:rows="12"
class="mpd-dataview"
>
<template #grid="slotProps">
<div class="mpd-grid">
<DocumentCard
v-for="doc in slotProps.items"
:key="doc.id"
:doc="doc"
@preview="onPreview"
@download="onDownload"
@edit="onEdit"
@delete="onDelete"
@share="onShare"
@sign="onSign"
/>
</div>
</template>
</DataView>
</main>
</div>
<!-- Dialogs reaproveitados -->
<DocumentUploadDialog
v-model:visible="uploadDlg"
:patient-id="patientId"
@uploaded="onUploaded"
/>
<DocumentPreviewDialog
v-model:visible="previewDlg"
:doc="selectedDoc"
:preview-url="previewUrl"
@updated="fetchDocuments"
/>
<DocumentGenerateDialog
v-if="patientId"
v-model:visible="generateDlg"
:patient-id="patientId"
:patient-name="patientName"
@generated="onGenerated"
/>
<DocumentSignatureDialog
:visible="signatureDlg"
@update:visible="signatureDlg = $event"
:doc="selectedDoc"
/>
<DocumentShareDialog
v-model:visible="shareDlg"
:doc="selectedDoc"
/>
</section>
</template>
<style scoped>
/* ═══════ Page chrome ═══════ */
.mpd-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 14px;
overflow: hidden;
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mpd-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mpd-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 600;
}
.mpd-page__title-icon { color: var(--p-primary-color); font-size: 1rem; }
.mpd-page__count {
font-size: 0.7rem;
font-weight: 600;
color: var(--m-accent);
background: var(--m-accent-soft);
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
padding: 2px 8px;
border-radius: 999px;
}
.mpd-page__actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.mpd-act-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
}
.mpd-act-btn:hover:not(:disabled) { background: var(--m-bg-soft-hover); }
.mpd-act-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mpd-act-btn--primary {
background: transparent;
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mpd-act-btn--primary:hover:not(:disabled) { background: color-mix(in srgb, var(--p-primary-color) 10%, transparent); }
.mpd-act-btn > i { font-size: 0.78rem; }
.mpd-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mpd-menu-btn:hover { background: var(--m-bg-soft-hover); }
/* ═══════ Body 2-col ═══════ */
.mpd-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(220px, 260px) 1fr;
gap: 12px;
padding: 12px;
overflow: hidden;
}
/* COL 1 — Sidebar */
.mpd-side {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mpd-side__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
font-size: 0.86rem;
font-weight: 700;
flex-shrink: 0;
}
.mpd-side__head > i { color: var(--m-text-muted); font-size: 0.9rem; }
.mpd-side__list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0;
padding: 6px;
list-style: none;
scrollbar-width: thin;
}
.mpd-side__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: transparent;
border-radius: 7px;
cursor: pointer;
font-size: 0.82rem;
transition: background-color 120ms ease, color 120ms ease;
}
.mpd-side__item:hover,
.mpd-side__item:focus-visible {
background: var(--m-bg-soft-hover);
outline: none;
}
.mpd-side__item.is-active {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--m-bg-medium));
color: var(--p-primary-color);
font-weight: 600;
}
.mpd-side__item.is-empty { opacity: 0.5; }
.mpd-side__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mpd-side__item-count {
font-size: 0.7rem;
color: var(--m-text-muted);
background: var(--m-bg-medium);
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.mpd-side__item.is-active .mpd-side__item-count {
color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
}
/* COL 2 — Main */
.mpd-main {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mpd-main__head {
padding: 12px 14px 8px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
}
.mpd-main__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mpd-main__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 700;
}
.mpd-main__title > i { color: var(--p-primary-color); font-size: 0.9rem; }
.mpd-main__subtitle {
margin: 6px 0 0;
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.45;
}
.mpd-loading,
.mpd-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 28px;
text-align: center;
color: var(--m-text-muted);
gap: 8px;
}
.mpd-loading > i { font-size: 1.4rem; color: var(--p-primary-color); }
.mpd-empty__icon { font-size: 2.4rem; color: var(--m-text-faint); margin-bottom: 6px; }
.mpd-empty__title { font-size: 0.95rem; font-weight: 600; color: var(--m-text); }
.mpd-empty__hint { font-size: 0.82rem; max-width: 360px; line-height: 1.5; }
.mpd-empty__actions { display: flex; gap: 8px; margin-top: 10px; }
.mpd-link {
background: none; border: none; color: var(--p-primary-color);
cursor: pointer; padding: 0; font: inherit; text-decoration: underline;
}
.mpd-dataview {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
.mpd-dataview :deep(.p-dataview-content) {
flex: 1; min-height: 0; overflow-y: auto;
background: transparent;
scrollbar-width: thin;
}
.mpd-dataview :deep(.p-dataview-paginator-bottom) {
flex-shrink: 0; background: transparent;
border-top: 1px solid var(--m-border);
}
.mpd-grid {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
/* ═══════ Mobile drawer ═══════ */
.mpd-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh; height: 100dvh;
width: min(320px, 86vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
display: flex;
flex-direction: column;
}
.mpd-mobile-drawer.is-open { transform: translateX(0); }
.mpd-mobile-drawer:not(.is-open) { pointer-events: none; }
.mpd-mobile-drawer__scroll {
flex: 1; min-height: 0; overflow-y: auto;
padding: 12px; display: flex; flex-direction: column;
}
.mpd-mobile-drawer__scroll .mpd-side {
width: 100%; height: 100%; border: 1px solid var(--m-border);
}
.mpd-mobile-drawer__backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
z-index: 79;
}
.mpd-drawer-fade-enter-active,
.mpd-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mpd-drawer-fade-enter-from,
.mpd-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mpd-body {
grid-template-columns: 1fr;
overflow: hidden;
}
.mpd-body > .mpd-side { display: none; }
.mpd-menu-btn--mobile-only { display: inline-flex; }
.mpd-page__title > span:first-of-type { display: none; }
.mpd-page__title-icon { display: none; }
.mpd-act-btn span { display: none; }
.mpd-act-btn { width: 32px; padding: 0; justify-content: center; }
}
</style>