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,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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentCard.vue
|
||||
| Card reutilizavel de documento — thumbnail, nome, tipo, data, tags, acoes.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doc: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['preview', 'download', 'edit', 'delete', 'share', 'sign'])
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
const mimeIcon = computed(() => {
|
||||
const m = String(props.doc.mime_type || '')
|
||||
if (m.startsWith('image/')) return 'pi pi-image'
|
||||
if (m === 'application/pdf') return 'pi pi-file-pdf'
|
||||
if (m.includes('word') || m.includes('document')) return 'pi pi-file-word'
|
||||
if (m.includes('excel') || m.includes('sheet')) return 'pi pi-file-excel'
|
||||
return 'pi pi-file'
|
||||
})
|
||||
|
||||
const mimeColor = computed(() => {
|
||||
const m = String(props.doc.mime_type || '')
|
||||
if (m.startsWith('image/')) return 'bg-purple-500/10 text-purple-500'
|
||||
if (m === 'application/pdf') return 'bg-red-500/10 text-red-500'
|
||||
if (m.includes('word')) return 'bg-blue-500/10 text-blue-500'
|
||||
if (m.includes('excel')) return 'bg-green-500/10 text-green-500'
|
||||
return 'bg-gray-500/10 text-gray-500'
|
||||
})
|
||||
|
||||
const tipoLabel = computed(() => {
|
||||
const map = {
|
||||
laudo: 'Laudo', receita: 'Receita', exame: 'Exame',
|
||||
termo_assinado: 'Termo', relatorio_externo: 'Relatório',
|
||||
identidade: 'Identidade', convenio: 'Convênio',
|
||||
declaracao: 'Declaração', atestado: 'Atestado',
|
||||
recibo: 'Recibo', outro: 'Outro'
|
||||
}
|
||||
return map[props.doc.tipo_documento] || 'Documento'
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
const b = props.doc.tamanho_bytes
|
||||
if (!b) return '—'
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = props.doc.uploaded_at
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
})
|
||||
|
||||
const isImage = computed(() => String(props.doc.mime_type || '').startsWith('image/'))
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{ label: 'Visualizar', icon: 'pi pi-eye', command: () => emit('preview', props.doc) },
|
||||
{ label: 'Baixar', icon: 'pi pi-download', command: () => emit('download', props.doc) },
|
||||
{ label: 'Editar', icon: 'pi pi-pencil', command: () => emit('edit', props.doc) },
|
||||
{ label: 'Compartilhar', icon: 'pi pi-share-alt', command: () => emit('share', props.doc) },
|
||||
{ label: 'Assinar', icon: 'pi pi-check-square', command: () => emit('sign', props.doc) },
|
||||
{ separator: true },
|
||||
{ label: 'Excluir', icon: 'pi pi-trash', class: 'text-red-500', command: () => emit('delete', props.doc) }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative flex items-start gap-3 p-3 rounded-lg border transition-all cursor-pointer"
|
||||
:class="[
|
||||
selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)]'
|
||||
]"
|
||||
@click="emit('preview', doc)"
|
||||
>
|
||||
<!-- Icone / Thumbnail -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="mimeColor">
|
||||
<i :class="mimeIcon" class="text-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate">{{ doc.nome_original }}</span>
|
||||
<span
|
||||
v-if="doc.enviado_pelo_paciente"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-600 whitespace-nowrap"
|
||||
>
|
||||
paciente
|
||||
</span>
|
||||
<span
|
||||
v-if="doc.status_revisao === 'pendente'"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 whitespace-nowrap"
|
||||
>
|
||||
pendente
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{{ tipoLabel }}</span>
|
||||
<span class="opacity-30">|</span>
|
||||
<span>{{ formattedSize }}</span>
|
||||
<span class="opacity-30">|</span>
|
||||
<span>{{ formattedDate }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="doc.tags?.length" class="flex flex-wrap gap-1 mt-1.5">
|
||||
<span
|
||||
v-for="tag in doc.tags"
|
||||
:key="tag"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full border border-[var(--surface-border)] text-[var(--text-color-secondary)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu de acoes -->
|
||||
<div class="flex-shrink-0 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.toggle($event)"
|
||||
/>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true" />
|
||||
</div>
|
||||
|
||||
<!-- Badges de visibilidade -->
|
||||
<div class="absolute top-2 right-2 flex gap-1" v-if="doc.compartilhado_portal || doc.compartilhado_supervisor">
|
||||
<i
|
||||
v-if="doc.compartilhado_portal"
|
||||
class="pi pi-user text-[0.6rem] p-1 rounded-full bg-blue-500/10 text-blue-500"
|
||||
v-tooltip.top="'Visível no portal do paciente'"
|
||||
/>
|
||||
<i
|
||||
v-if="doc.compartilhado_supervisor"
|
||||
class="pi pi-eye text-[0.6rem] p-1 rounded-full bg-teal-500/10 text-teal-500"
|
||||
v-tooltip.top="'Compartilhado com supervisor'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentGenerateDialog.vue
|
||||
| Gerar documento: selecionar template, preencher, preview, gerar PDF.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useDocumentGenerate } from '../composables/useDocumentGenerate'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
agendaEventoId: { type: String, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'generated'])
|
||||
|
||||
const toast = useToast()
|
||||
const step = ref('select') // select | edit | preview
|
||||
|
||||
const {
|
||||
loading: generating,
|
||||
error: genError,
|
||||
variables,
|
||||
selectedTemplate,
|
||||
previewHtml,
|
||||
loadVariables,
|
||||
selectTemplate,
|
||||
setVariable,
|
||||
updatePreview,
|
||||
generateAndSave,
|
||||
downloadOnly,
|
||||
printDocument,
|
||||
reset
|
||||
} = useDocumentGenerate()
|
||||
|
||||
const {
|
||||
templates,
|
||||
loading: loadingTemplates,
|
||||
fetchTemplates,
|
||||
TEMPLATE_VARIABLES
|
||||
} = useDocumentTemplates()
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
step.value = 'select'
|
||||
reset()
|
||||
await Promise.all([
|
||||
fetchTemplates(),
|
||||
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
// ── Selecionar template ─────────────────────────────────────
|
||||
|
||||
async function onSelectTemplate(tpl) {
|
||||
await selectTemplate(tpl.id)
|
||||
step.value = 'edit'
|
||||
}
|
||||
|
||||
// ── Variaveis editaveis ─────────────────────────────────────
|
||||
|
||||
const editableVars = computed(() => {
|
||||
if (!selectedTemplate.value?.variaveis?.length) return []
|
||||
return selectedTemplate.value.variaveis.map(key => {
|
||||
const meta = TEMPLATE_VARIABLES.find(v => v.key === key)
|
||||
return {
|
||||
key,
|
||||
label: meta?.label || key,
|
||||
grupo: meta?.grupo || 'Outros',
|
||||
value: variables.value[key] || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const varGroups = computed(() => {
|
||||
const groups = {}
|
||||
for (const v of editableVars.value) {
|
||||
if (!groups[v.grupo]) groups[v.grupo] = []
|
||||
groups[v.grupo].push(v)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function onVarChange(key, val) {
|
||||
setVariable(key, val)
|
||||
}
|
||||
|
||||
// ── Gerar ───────────────────────────────────────────────────
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
const result = await generateAndSave(props.patientId)
|
||||
toast.add({ severity: 'success', summary: 'Documento salvo', detail: 'Disponível nos documentos do paciente.', life: 3000 })
|
||||
emit('generated', result)
|
||||
close()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar PDF.' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDownloadOnly() {
|
||||
try {
|
||||
await downloadOnly()
|
||||
toast.add({ severity: 'info', summary: 'Download', detail: 'PDF baixado (não salvo no sistema).', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar PDF.' })
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
maximizable
|
||||
:draggable="false"
|
||||
:closable="!generating"
|
||||
:dismissableMask="!generating"
|
||||
class="w-[60rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '96vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-green-500/10">
|
||||
<i class="pi pi-file-pdf text-green-600" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Gerar documento</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
<template v-if="step === 'select'">Selecione um template</template>
|
||||
<template v-else-if="step === 'edit'">{{ selectedTemplate?.nome_template }} — {{ patientName }}</template>
|
||||
<template v-else>Preview do documento</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 1: Selecionar template -->
|
||||
<div v-if="step === 'select'">
|
||||
<div v-if="loadingTemplates" class="flex items-center justify-center py-12">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
<div v-else-if="!templates.length" class="text-center py-12 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-inbox text-3xl opacity-40 mb-2" />
|
||||
<div class="text-sm">Nenhum template disponível.</div>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
class="flex items-start gap-3 p-3 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 text-left transition-all"
|
||||
@click="onSelectTemplate(tpl)"
|
||||
>
|
||||
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i class="pi pi-file text-primary" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tpl.descricao || tpl.tipo }}</div>
|
||||
<span
|
||||
v-if="tpl.is_global"
|
||||
class="inline-block mt-1 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600"
|
||||
>
|
||||
padrão
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Editar variaveis -->
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
|
||||
<div v-for="(vars, grupo) in varGroups" :key="grupo">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
|
||||
<InputText
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button label="Voltar" text icon="pi pi-arrow-left" @click="step = 'select'; reset()" />
|
||||
<Button label="Preview" icon="pi pi-eye" @click="updatePreview(); step = 'preview'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Preview -->
|
||||
<div v-else-if="step === 'preview'">
|
||||
<div class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<iframe
|
||||
:srcdoc="previewHtml"
|
||||
class="w-full min-h-[60vh] border-0"
|
||||
sandbox=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="genError" class="mt-3 text-sm text-red-500 flex items-center gap-1.5">
|
||||
<i class="pi pi-exclamation-circle text-xs" />
|
||||
{{ genError }}
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Editar"
|
||||
text
|
||||
icon="pi pi-arrow-left"
|
||||
@click="step = 'edit'"
|
||||
:disabled="generating"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Cancelar" text @click="close" :disabled="generating" />
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Só baixar"
|
||||
text
|
||||
icon="pi pi-download"
|
||||
@click="onDownloadOnly"
|
||||
:loading="generating"
|
||||
/>
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Salvar documento"
|
||||
icon="pi pi-check"
|
||||
@click="onGenerate"
|
||||
:loading="generating"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentPreviewDialog.vue
|
||||
| Preview inline de PDF/imagem + metadados + acoes.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null },
|
||||
previewUrl: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'download', 'edit', 'delete', 'share', 'sign'])
|
||||
|
||||
const toast = useToast()
|
||||
const activeTab = ref('preview')
|
||||
|
||||
// ── Computed ────────────────────────────────────────────────
|
||||
|
||||
const isImage = computed(() => String(props.doc?.mime_type || '').startsWith('image/'))
|
||||
const isPdf = computed(() => props.doc?.mime_type === 'application/pdf')
|
||||
const canPreview = computed(() => isImage.value || isPdf.value)
|
||||
|
||||
const tipoLabel = computed(() => {
|
||||
const map = {
|
||||
laudo: 'Laudo', receita: 'Receita', exame: 'Exame',
|
||||
termo_assinado: 'Termo assinado', relatorio_externo: 'Relatório externo',
|
||||
identidade: 'Identidade', convenio: 'Convênio',
|
||||
declaracao: 'Declaração', atestado: 'Atestado',
|
||||
recibo: 'Recibo', outro: 'Outro'
|
||||
}
|
||||
return map[props.doc?.tipo_documento] || 'Documento'
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
const b = props.doc?.tamanho_bytes
|
||||
if (!b) return '—'
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = props.doc?.uploaded_at
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
const visibilidadeLabel = computed(() => {
|
||||
const map = {
|
||||
privado: 'Privado',
|
||||
compartilhado_supervisor: 'Supervisor',
|
||||
compartilhado_portal: 'Portal paciente'
|
||||
}
|
||||
return map[props.doc?.visibilidade] || 'Privado'
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
maximizable
|
||||
:draggable="false"
|
||||
class="w-[55rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '96vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-0' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-500/10">
|
||||
<i class="pi pi-eye text-indigo-500" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">{{ doc?.nome_original }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">{{ tipoLabel }} · {{ formattedSize }} · {{ formattedDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="doc" class="flex flex-col lg:flex-row">
|
||||
<!-- Preview area -->
|
||||
<div class="flex-1 min-h-[400px] flex items-center justify-center bg-[var(--surface-ground)] p-4">
|
||||
<template v-if="canPreview && previewUrl">
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="previewUrl"
|
||||
:alt="doc.nome_original"
|
||||
class="max-w-full max-h-[70vh] rounded shadow-sm"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="previewUrl"
|
||||
class="w-full h-[70vh] rounded border-0"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="flex flex-col items-center gap-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-file text-5xl opacity-40" />
|
||||
<span class="text-sm">Preview não disponível para este tipo de arquivo.</span>
|
||||
<Button label="Baixar arquivo" icon="pi pi-download" size="small" @click="emit('download', doc)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar de detalhes -->
|
||||
<div class="w-full lg:w-[240px] border-t lg:border-t-0 lg:border-l border-[var(--surface-border)] p-4 flex flex-col gap-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Detalhes</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Tipo</div>
|
||||
<div class="text-sm">{{ tipoLabel }}</div>
|
||||
</div>
|
||||
<div v-if="doc.categoria">
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Categoria</div>
|
||||
<div class="text-sm">{{ doc.categoria }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Visibilidade</div>
|
||||
<div class="text-sm">{{ visibilidadeLabel }}</div>
|
||||
</div>
|
||||
<div v-if="doc.descricao">
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-0.5">Descrição</div>
|
||||
<div class="text-sm">{{ doc.descricao }}</div>
|
||||
</div>
|
||||
<div v-if="doc.tags?.length">
|
||||
<div class="text-[0.65rem] uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">Tags</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in doc.tags"
|
||||
:key="tag"
|
||||
class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acoes -->
|
||||
<div class="mt-auto flex flex-col gap-1.5 pt-3 border-t border-[var(--surface-border)]">
|
||||
<Button label="Baixar" icon="pi pi-download" size="small" class="w-full" @click="emit('download', doc)" />
|
||||
<Button label="Editar" icon="pi pi-pencil" size="small" text class="w-full" @click="emit('edit', doc)" />
|
||||
<Button label="Compartilhar" icon="pi pi-share-alt" size="small" text class="w-full" @click="emit('share', doc)" />
|
||||
<Button label="Assinar" icon="pi pi-check-square" size="small" text class="w-full" @click="emit('sign', doc)" />
|
||||
<Button label="Excluir" icon="pi pi-trash" size="small" text severity="danger" class="w-full" @click="emit('delete', doc)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<Button label="Fechar" text @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,245 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentShareDialog.vue
|
||||
| Gerar link temporario para compartilhamento externo.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
createShareLink,
|
||||
listShareLinks,
|
||||
deactivateShareLink,
|
||||
buildShareUrl
|
||||
} from '@/services/DocumentShareLinks.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const links = ref([])
|
||||
|
||||
const OPCOES_EXPIRACAO = [
|
||||
{ value: 24, label: '24 horas' },
|
||||
{ value: 48, label: '48 horas' },
|
||||
{ value: 168, label: '7 dias' },
|
||||
{ value: 720, label: '30 dias' }
|
||||
]
|
||||
|
||||
const formExpiracao = ref(48)
|
||||
const formUsosMax = ref(5)
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.doc) {
|
||||
formExpiracao.value = 48
|
||||
formUsosMax.value = 5
|
||||
await fetchLinks()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchLinks() {
|
||||
loading.value = true
|
||||
try {
|
||||
links.value = await listShareLinks(props.doc.id)
|
||||
} catch {
|
||||
links.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Criar link ──────────────────────────────────────────────
|
||||
|
||||
async function criarLink() {
|
||||
creating.value = true
|
||||
try {
|
||||
const link = await createShareLink(props.doc.id, {
|
||||
expiracaoHoras: formExpiracao.value,
|
||||
usosMax: formUsosMax.value
|
||||
})
|
||||
links.value.unshift(link)
|
||||
toast.add({ severity: 'success', summary: 'Link criado', detail: 'Link copiado para a área de transferência.', life: 3000 })
|
||||
copyUrl(link.token)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar link.' })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copiar URL ──────────────────────────────────────────────
|
||||
|
||||
function copyUrl(token) {
|
||||
const url = buildShareUrl(token)
|
||||
navigator.clipboard.writeText(url).catch(() => {})
|
||||
}
|
||||
|
||||
// ── Desativar link ──────────────────────────────────────────
|
||||
|
||||
async function desativar(linkId) {
|
||||
try {
|
||||
await deactivateShareLink(linkId)
|
||||
const idx = links.value.findIndex(l => l.id === linkId)
|
||||
if (idx >= 0) links.value[idx].ativo = false
|
||||
toast.add({ severity: 'info', summary: 'Link desativado', life: 2000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function isExpired(link) {
|
||||
return new Date(link.expira_em) < new Date()
|
||||
}
|
||||
|
||||
function isExhausted(link) {
|
||||
return link.usos >= link.usos_max
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
class="w-[36rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-500/10">
|
||||
<i class="pi pi-share-alt text-orange-500" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Compartilhar documento</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate max-w-[300px]">{{ doc?.nome_original }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Criar novo link -->
|
||||
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">Novo link</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Expira em</label>
|
||||
<Select
|
||||
v-model="formExpiracao"
|
||||
:options="OPCOES_EXPIRACAO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Limite de acessos</label>
|
||||
<InputNumber v-model="formUsosMax" :min="1" :max="100" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Gerar link"
|
||||
icon="pi pi-link"
|
||||
size="small"
|
||||
:loading="creating"
|
||||
@click="criarLink"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Links existentes -->
|
||||
<div v-if="links.length">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">Links criados</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 max-h-[250px] overflow-y-auto">
|
||||
<div
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex items-center gap-2 p-2.5 rounded-md border border-[var(--surface-border)]"
|
||||
:class="{ 'opacity-50': !link.ativo || isExpired(link) || isExhausted(link) }"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
!link.ativo ? 'pi pi-ban text-gray-400' :
|
||||
isExpired(link) ? 'pi pi-clock text-red-400' :
|
||||
isExhausted(link) ? 'pi pi-exclamation-circle text-amber-400' :
|
||||
'pi pi-link text-green-500'
|
||||
"
|
||||
class="text-sm flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Expira: {{ formatDate(link.expira_em) }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Usos: {{ link.usos }}/{{ link.usos_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
v-if="link.ativo && !isExpired(link)"
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7"
|
||||
v-tooltip.top="'Copiar link'"
|
||||
@click="copyUrl(link.token)"
|
||||
/>
|
||||
<Button
|
||||
v-if="link.ativo"
|
||||
icon="pi pi-ban"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
class="!w-7 !h-7"
|
||||
v-tooltip.top="'Desativar'"
|
||||
@click="desativar(link.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<Button label="Fechar" text @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,306 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentSignatureDialog.vue
|
||||
| Solicitar assinatura: adicionar signatarios, acompanhar status.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import {
|
||||
createSignatureRequests,
|
||||
listSignatures,
|
||||
getSignatureStatus
|
||||
} from '@/services/DocumentSignatures.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'requested'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const existingSignatures = ref([])
|
||||
const signatureStatus = ref(null)
|
||||
|
||||
const TIPOS_SIGNATARIO = [
|
||||
{ value: 'paciente', label: 'Paciente' },
|
||||
{ value: 'responsavel_legal', label: 'Responsável legal' },
|
||||
{ value: 'terapeuta', label: 'Terapeuta' }
|
||||
]
|
||||
|
||||
// Signatarios a adicionar
|
||||
const signatarios = ref([])
|
||||
const patientEmails = ref([])
|
||||
|
||||
function addSignatario() {
|
||||
signatarios.value.push({ tipo: 'paciente', nome: '', email: '' })
|
||||
}
|
||||
|
||||
function removeSignatario(idx) {
|
||||
signatarios.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// ── Buscar emails do paciente ──────────────────────────────
|
||||
|
||||
async function fetchPatientEmails(patientId) {
|
||||
if (!patientId) { patientEmails.value = []; return }
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('patients')
|
||||
.select('email_principal, email_alternativo')
|
||||
.eq('id', patientId)
|
||||
.single()
|
||||
|
||||
const emails = []
|
||||
if (data?.email_principal) emails.push(data.email_principal)
|
||||
if (data?.email_alternativo && data.email_alternativo !== data.email_principal) emails.push(data.email_alternativo)
|
||||
patientEmails.value = emails
|
||||
} catch {
|
||||
patientEmails.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function useEmail(email) {
|
||||
// Preenche o último signatário adicionado que não tenha email, ou o primeiro vazio
|
||||
const target = signatarios.value.findLast(s => !s.email?.trim()) || signatarios.value[signatarios.value.length - 1]
|
||||
if (target) target.email = email
|
||||
}
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.doc) {
|
||||
signatarios.value = []
|
||||
loading.value = true
|
||||
try {
|
||||
const [sigs, status] = await Promise.all([
|
||||
listSignatures(props.doc.id),
|
||||
getSignatureStatus(props.doc.id),
|
||||
fetchPatientEmails(props.doc.patient_id)
|
||||
])
|
||||
existingSignatures.value = sigs
|
||||
signatureStatus.value = status
|
||||
} catch {
|
||||
existingSignatures.value = []
|
||||
signatureStatus.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Status badge ────────────────────────────────────────────
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const s = signatureStatus.value?.status
|
||||
if (s === 'completo') return 'bg-green-500/10 text-green-600'
|
||||
if (s === 'parcial') return 'bg-amber-500/10 text-amber-600'
|
||||
return 'bg-gray-500/10 text-gray-500'
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const s = signatureStatus.value?.status
|
||||
if (s === 'completo') return 'Todas assinaturas completas'
|
||||
if (s === 'parcial') return `${signatureStatus.value.assinados}/${signatureStatus.value.total} assinado(s)`
|
||||
if (s === 'pendente') return 'Aguardando assinaturas'
|
||||
return 'Sem assinaturas'
|
||||
})
|
||||
|
||||
// ── Enviar solicitacao ──────────────────────────────────────
|
||||
|
||||
async function submit() {
|
||||
if (!signatarios.value.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Adicione ao menos um signatário.' })
|
||||
return
|
||||
}
|
||||
|
||||
const semNome = signatarios.value.find(s => !s.nome?.trim())
|
||||
if (semNome) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preencha o nome de todos os signatários.' })
|
||||
return
|
||||
}
|
||||
|
||||
const semEmail = signatarios.value.find(s => !s.email?.trim())
|
||||
if (semEmail) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preencha o e-mail de todos os signatários.' })
|
||||
return
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const emailInvalido = signatarios.value.find(s => !emailRegex.test(s.email?.trim()))
|
||||
if (emailInvalido) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: `E-mail inválido: ${emailInvalido.email}` })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await createSignatureRequests(props.doc.id, signatarios.value)
|
||||
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
|
||||
emit('requested', result)
|
||||
emit('update:visible', false)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao solicitar assinatura.' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="w-[38rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-500/10">
|
||||
<i class="pi pi-check-square text-teal-600" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Assinatura eletrônica</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate max-w-[300px]">{{ doc?.nome_original }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<i class="pi pi-spinner pi-spin text-xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<!-- Status atual -->
|
||||
<div v-if="existingSignatures.length" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Assinaturas existentes</span>
|
||||
<span class="text-[0.65rem] px-2 py-0.5 rounded-full" :class="statusColor">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="sig in existingSignatures"
|
||||
:key="sig.id"
|
||||
class="flex items-center gap-2 p-2 rounded-md bg-[var(--surface-ground)]"
|
||||
>
|
||||
<i
|
||||
:class="sig.status === 'assinado' ? 'pi pi-check-circle text-green-500' : sig.status === 'recusado' ? 'pi pi-times-circle text-red-500' : 'pi pi-clock text-amber-500'"
|
||||
class="text-sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm">{{ sig.signatario_nome || sig.signatario_tipo }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] ml-2">{{ sig.signatario_tipo }}</span>
|
||||
</div>
|
||||
<span v-if="sig.assinado_em" class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ new Date(sig.assinado_em).toLocaleDateString('pt-BR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adicionar novos signatarios -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Novos signatários</span>
|
||||
<Button label="Adicionar" icon="pi pi-plus" size="small" text @click="addSignatario" />
|
||||
</div>
|
||||
|
||||
<div v-if="!signatarios.length" class="text-center py-4 text-sm text-[var(--text-color-secondary)]">
|
||||
Clique em "Adicionar" para incluir signatários.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-for="(sig, idx) in signatarios"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[120px_1fr_1fr_auto] gap-2 items-end"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select
|
||||
v-model="sig.tipo"
|
||||
:options="TIPOS_SIGNATARIO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">Nome <span class="text-red-400">*</span></label>
|
||||
<InputText v-model="sig.nome" placeholder="Nome" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[0.65rem] text-[var(--text-color-secondary)]">E-mail <span class="text-red-400">*</span></label>
|
||||
<InputText v-model="sig.email" placeholder="email@..." class="w-full" />
|
||||
</div>
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="removeSignatario(idx)" class="mb-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails cadastrados do paciente -->
|
||||
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">E-mails do paciente</div>
|
||||
<div v-if="patientEmails.length" class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(email, i) in patientEmails"
|
||||
:key="i"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<InputText :modelValue="email" readonly class="w-full !text-xs !bg-transparent" />
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7 flex-shrink-0"
|
||||
v-tooltip.top="'Copiar e usar'"
|
||||
@click="useEmail(email)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-[var(--text-color-secondary)] italic py-1">
|
||||
Nenhum e-mail cadastrado anteriormente foi encontrado.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button label="Cancelar" text @click="close" :disabled="saving" />
|
||||
<Button
|
||||
label="Solicitar assinatura"
|
||||
icon="pi pi-send"
|
||||
:loading="saving"
|
||||
:disabled="!signatarios.length"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentTagsInput.vue
|
||||
| Input de tags livres com chips editaveis e autocomplete.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
suggestions: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Adicionar tag...' },
|
||||
maxTags: { type: Number, default: 20 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const inputValue = ref('')
|
||||
const inputRef = ref(null)
|
||||
const showSuggestions = ref(false)
|
||||
|
||||
const tags = computed({
|
||||
get: () => props.modelValue || [],
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
const q = inputValue.value.toLowerCase().trim()
|
||||
if (!q) return []
|
||||
return props.suggestions
|
||||
.filter(s => s.toLowerCase().includes(q) && !tags.value.includes(s))
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
function addTag(value) {
|
||||
const tag = String(value || '').trim().toLowerCase()
|
||||
if (!tag) return
|
||||
if (tags.value.includes(tag)) return
|
||||
if (tags.value.length >= props.maxTags) return
|
||||
|
||||
tags.value = [...tags.value, tag]
|
||||
inputValue.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeTag(index) {
|
||||
const copy = [...tags.value]
|
||||
copy.splice(index, 1)
|
||||
tags.value = copy
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addTag(inputValue.value)
|
||||
}
|
||||
if (e.key === 'Backspace' && !inputValue.value && tags.value.length) {
|
||||
removeTag(tags.value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
showSuggestions.value = inputValue.value.trim().length > 0
|
||||
}
|
||||
|
||||
function selectSuggestion(s) {
|
||||
addTag(s)
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 min-h-[2.5rem] px-2.5 py-1.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-overlay)] focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-colors"
|
||||
>
|
||||
<!-- Tags existentes -->
|
||||
<span
|
||||
v-for="(tag, idx) in tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary"
|
||||
>
|
||||
{{ tag }}
|
||||
<i
|
||||
class="pi pi-times text-[0.55rem] cursor-pointer opacity-60 hover:opacity-100"
|
||||
@click="removeTag(idx)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<!-- Input -->
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder="tags.length ? '' : placeholder"
|
||||
class="!border-0 !shadow-none !ring-0 !p-0 !min-w-[80px] flex-1 text-sm !bg-transparent"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
@focus="onInput"
|
||||
@blur="setTimeout(() => showSuggestions = false, 150)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown sugestoes -->
|
||||
<div
|
||||
v-if="showSuggestions && filteredSuggestions.length"
|
||||
class="absolute z-50 top-full left-0 right-0 mt-1 py-1 rounded-md border border-[var(--surface-border)] bg-[var(--surface-overlay)] shadow-lg max-h-[200px] overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="s in filteredSuggestions"
|
||||
:key="s"
|
||||
class="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--surface-hover)] transition-colors"
|
||||
@mousedown.prevent="selectSuggestion(s)"
|
||||
>
|
||||
{{ s }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentTemplateEditor.vue
|
||||
| Editor de template: edicao HTML, insercao de variaveis, preview ao vivo.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
mode: { type: String, default: 'create' } // create | edit
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
|
||||
|
||||
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
|
||||
|
||||
const activeTab = ref('editor') // editor | preview
|
||||
|
||||
// ── Form reativo synced com modelValue ──────────────────────
|
||||
|
||||
const form = ref({ ...defaultForm(), ...props.modelValue })
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
nome_template: '',
|
||||
tipo: 'outro',
|
||||
descricao: '',
|
||||
corpo_html: '',
|
||||
cabecalho_html: '',
|
||||
rodape_html: '',
|
||||
variaveis: [],
|
||||
logo_url: ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
form.value = { ...defaultForm(), ...val }
|
||||
}, { deep: true })
|
||||
|
||||
watch(form, (val) => {
|
||||
emit('update:modelValue', { ...val })
|
||||
}, { deep: true })
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────
|
||||
|
||||
const renderedPreview = computed(() => previewHtml(form.value.corpo_html))
|
||||
const renderedCabecalho = computed(() => previewHtml(form.value.cabecalho_html || ''))
|
||||
const renderedRodape = computed(() => previewHtml(form.value.rodape_html || ''))
|
||||
|
||||
// ── Inserir variavel no corpo ───────────────────────────────
|
||||
|
||||
const cursorField = ref('corpo_html') // qual campo esta ativo
|
||||
const editorCabecalho = ref(null)
|
||||
const editorCorpo = ref(null)
|
||||
const editorRodape = ref(null)
|
||||
|
||||
function insertVariable(varKey) {
|
||||
const tag = `{{${varKey}}}`
|
||||
const editorMap = {
|
||||
cabecalho_html: editorCabecalho,
|
||||
corpo_html: editorCorpo,
|
||||
rodape_html: editorRodape
|
||||
}
|
||||
const editorRef = editorMap[cursorField.value]
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag)
|
||||
} else {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
|
||||
}
|
||||
|
||||
// Adiciona a variavel na lista se nao estiver
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────
|
||||
|
||||
function onSave() {
|
||||
emit('save', { ...form.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Header: nome e tipo -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_200px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select
|
||||
v-model="form.tipo"
|
||||
:options="TIPOS_TEMPLATE"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Editor / Preview -->
|
||||
<div class="flex items-center gap-1 border-b border-[var(--surface-border)]">
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'editor' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = 'editor'"
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'preview' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = 'preview'"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel de variaveis -->
|
||||
<div class="w-full lg:w-[220px] flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
|
||||
Variáveis
|
||||
</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)] mb-3">
|
||||
Clique para inserir no campo ativo
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 max-h-[500px] overflow-y-auto pr-1">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.65rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-2 py-1 rounded hover:bg-primary/10 hover:text-primary transition-colors truncate"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="font-mono text-[0.65rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.65rem] opacity-60">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
|
||||
<div class="min-h-[300px]" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acoes -->
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button label="Cancelar" text @click="emit('cancel')" />
|
||||
<Button :label="mode === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" @click="onSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,279 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/features/documents/components/DocumentUploadDialog.vue
|
||||
| Dialog de upload — drag & drop, tipo, categoria, tags, visibilidade.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DocumentTagsInput from './DocumentTagsInput.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
usedTags: { type: Array, default: () => [] },
|
||||
sessions: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'uploaded'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────
|
||||
|
||||
const file = ref(null)
|
||||
const filePreviewUrl = ref('')
|
||||
const dragging = ref(false)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
|
||||
const form = reactive({
|
||||
tipo_documento: 'outro',
|
||||
categoria: '',
|
||||
descricao: '',
|
||||
tags: [],
|
||||
agenda_evento_id: null,
|
||||
visibilidade: 'privado',
|
||||
compartilhado_portal: false,
|
||||
compartilhado_supervisor: false
|
||||
})
|
||||
|
||||
const TIPOS = [
|
||||
{ value: 'laudo', label: 'Laudo' },
|
||||
{ value: 'receita', label: 'Receita' },
|
||||
{ value: 'exame', label: 'Exame' },
|
||||
{ value: 'termo_assinado', label: 'Termo assinado' },
|
||||
{ value: 'relatorio_externo', label: 'Relatório externo' },
|
||||
{ value: 'identidade', label: 'Identidade' },
|
||||
{ value: 'convenio', label: 'Convênio' },
|
||||
{ value: 'declaracao', label: 'Declaração' },
|
||||
{ value: 'atestado', label: 'Atestado' },
|
||||
{ value: 'recibo', label: 'Recibo' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
]
|
||||
|
||||
const VISIBILIDADES = [
|
||||
{ value: 'privado', label: 'Privado (só eu)' },
|
||||
{ value: 'compartilhado_supervisor', label: 'Compartilhado com supervisor' },
|
||||
{ value: 'compartilhado_portal', label: 'Visível no portal do paciente' }
|
||||
]
|
||||
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
file.value = null
|
||||
filePreviewUrl.value = ''
|
||||
formErr.value = ''
|
||||
Object.assign(form, {
|
||||
tipo_documento: 'outro',
|
||||
categoria: '',
|
||||
descricao: '',
|
||||
tags: [],
|
||||
agenda_evento_id: null,
|
||||
visibilidade: 'privado',
|
||||
compartilhado_portal: false,
|
||||
compartilhado_supervisor: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Sync visibilidade ───────────────────────────────────────
|
||||
|
||||
watch(() => form.visibilidade, (v) => {
|
||||
form.compartilhado_portal = v === 'compartilhado_portal'
|
||||
form.compartilhado_supervisor = v === 'compartilhado_supervisor'
|
||||
})
|
||||
|
||||
// ── File handling ───────────────────────────────────────────
|
||||
|
||||
const MAX_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
function onFileSelected(e) {
|
||||
const f = e.target?.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
dragging.value = false
|
||||
const f = e.dataTransfer?.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}
|
||||
|
||||
function setFile(f) {
|
||||
if (f.size > MAX_SIZE) {
|
||||
toast.add({ severity: 'warn', summary: 'Arquivo grande', detail: 'Máximo 50 MB.' })
|
||||
return
|
||||
}
|
||||
file.value = f
|
||||
filePreviewUrl.value = f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
|
||||
formErr.value = ''
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
file.value = null
|
||||
if (filePreviewUrl.value) URL.revokeObjectURL(filePreviewUrl.value)
|
||||
filePreviewUrl.value = ''
|
||||
}
|
||||
|
||||
const fileSizeFormatted = computed(() => {
|
||||
if (!file.value) return ''
|
||||
const b = file.value.size
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────
|
||||
|
||||
async function submit() {
|
||||
if (!file.value) { formErr.value = 'Selecione um arquivo.'; return }
|
||||
if (!props.patientId) { formErr.value = 'Paciente não informado.'; return }
|
||||
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
|
||||
try {
|
||||
emit('uploaded', { file: file.value, meta: { ...form } })
|
||||
close()
|
||||
} catch (e) {
|
||||
formErr.value = e?.message || 'Erro ao enviar arquivo.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="w-[40rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-4 !rounded-t-xl border-b border-[var(--surface-border)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-3 !rounded-b-xl border-t border-[var(--surface-border)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-500/10">
|
||||
<i class="pi pi-upload text-blue-500" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Upload de documento</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]" v-if="patientName">{{ patientName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
v-if="!file"
|
||||
class="flex flex-col items-center justify-center gap-3 p-8 rounded-lg border-2 border-dashed transition-colors cursor-pointer"
|
||||
:class="dragging ? 'border-primary bg-primary/5' : 'border-[var(--surface-border)] hover:border-[var(--surface-400)]'"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<i class="pi pi-cloud-upload text-3xl text-[var(--text-color-secondary)]" />
|
||||
<div class="text-sm text-[var(--text-color-secondary)] text-center">
|
||||
<span class="font-medium text-primary">Clique para selecionar</span> ou arraste o arquivo aqui
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] opacity-60">
|
||||
PDF, imagem, Word, Excel — até 50 MB
|
||||
</div>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileSelected" />
|
||||
</div>
|
||||
|
||||
<!-- Arquivo selecionado -->
|
||||
<div v-else class="flex items-center gap-3 p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<img v-if="filePreviewUrl" :src="filePreviewUrl" class="w-12 h-12 rounded object-cover" />
|
||||
<i v-else class="pi pi-file text-2xl text-[var(--text-color-secondary)]" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">{{ fileSizeFormatted }}</div>
|
||||
</div>
|
||||
<Button icon="pi pi-times" text rounded size="small" severity="danger" @click="removeFile" />
|
||||
</div>
|
||||
|
||||
<!-- Campos -->
|
||||
<div class="flex flex-col gap-3.5 mt-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo do documento</label>
|
||||
<Select
|
||||
v-model="form.tipo_documento"
|
||||
:options="TIPOS"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Visibilidade</label>
|
||||
<Select
|
||||
v-model="form.visibilidade"
|
||||
:options="VISIBILIDADES"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" v-if="sessions.length">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Vincular a sessão (opcional)</label>
|
||||
<Select
|
||||
v-model="form.agenda_evento_id"
|
||||
:options="sessions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Nenhuma sessão"
|
||||
showClear
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição (opcional)</label>
|
||||
<Textarea v-model="form.descricao" rows="2" autoResize class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tags</label>
|
||||
<DocumentTagsInput v-model="form.tags" :suggestions="usedTags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="formErr" class="mt-3 text-sm text-red-500 flex items-center gap-1.5">
|
||||
<i class="pi pi-exclamation-circle text-xs" />
|
||||
{{ formErr }}
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button label="Cancelar" text @click="close" :disabled="saving" />
|
||||
<Button label="Enviar" icon="pi pi-upload" :loading="saving" @click="submit" :disabled="!file" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/composables/useDocumentGenerate.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
loadAllVariables,
|
||||
fillTemplate,
|
||||
buildFullHtml,
|
||||
generatePdfBlob,
|
||||
generateAndDownloadPdf,
|
||||
printDocument as printPdf,
|
||||
saveGeneratedDocument,
|
||||
listGeneratedDocuments
|
||||
} from '@/services/DocumentGenerate.service';
|
||||
import { getTemplate } from '@/services/DocumentTemplates.service';
|
||||
|
||||
// ── Composable ──────────────────────────────────────────────
|
||||
|
||||
export function useDocumentGenerate() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const generatedDocs = ref([]);
|
||||
|
||||
// Dados carregados para preenchimento
|
||||
const variables = ref({});
|
||||
const selectedTemplate = ref(null);
|
||||
const previewHtml = ref('');
|
||||
|
||||
// ── Carregar variaveis do paciente/sessao ───────────────
|
||||
|
||||
async function loadVariables(patientId, agendaEventoId = null) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
variables.value = await loadAllVariables(patientId, agendaEventoId);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar dados do paciente.';
|
||||
variables.value = {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selecionar template e gerar preview ─────────────────
|
||||
|
||||
async function selectTemplate(templateId) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
selectedTemplate.value = await getTemplate(templateId);
|
||||
updatePreview();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar template.';
|
||||
selectedTemplate.value = null;
|
||||
previewHtml.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Atualizar preview ───────────────────────────────────
|
||||
|
||||
function updatePreview() {
|
||||
if (!selectedTemplate.value) {
|
||||
previewHtml.value = '';
|
||||
return;
|
||||
}
|
||||
previewHtml.value = buildFullHtml(selectedTemplate.value, variables.value);
|
||||
}
|
||||
|
||||
// ── Atualizar variavel individual ───────────────────────
|
||||
|
||||
function setVariable(key, value) {
|
||||
variables.value[key] = value;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ── Gerar PDF (client-side) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera PDF blob, faz download, salva no Storage + banco.
|
||||
*/
|
||||
async function generateAndSave(patientId) {
|
||||
if (!selectedTemplate.value) throw new Error('Nenhum template selecionado.');
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const templateNome = selectedTemplate.value.nome_template || 'documento';
|
||||
|
||||
// Gera PDF blob
|
||||
const blob = await generatePdfBlob(selectedTemplate.value, variables.value);
|
||||
|
||||
// Salva no Storage + banco (generated-docs + documents)
|
||||
const result = await saveGeneratedDocument({
|
||||
templateId: selectedTemplate.value.id,
|
||||
patientId,
|
||||
dadosPreenchidos: { ...variables.value },
|
||||
pdfBlob: blob,
|
||||
templateNome
|
||||
});
|
||||
generatedDocs.value.unshift(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao gerar documento.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera somente o PDF e faz download, sem salvar no banco.
|
||||
*/
|
||||
async function downloadOnly() {
|
||||
if (!selectedTemplate.value) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const templateNome = selectedTemplate.value?.nome_template || 'documento';
|
||||
const filename = `${templateNome.replace(/\s+/g, '_')}_${Date.now()}.pdf`;
|
||||
await generateAndDownloadPdf(selectedTemplate.value, variables.value, filename);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao gerar PDF.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre PDF em nova aba para impressao.
|
||||
*/
|
||||
function printDocument() {
|
||||
if (!selectedTemplate.value) return;
|
||||
printPdf(selectedTemplate.value, variables.value);
|
||||
}
|
||||
|
||||
// ── Carregar historico de documentos gerados ────────────
|
||||
|
||||
async function fetchGeneratedDocs(patientId) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
generatedDocs.value = await listGeneratedDocuments(patientId);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar documentos gerados.';
|
||||
generatedDocs.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset ───────────────────────────────────────────────
|
||||
|
||||
function reset() {
|
||||
selectedTemplate.value = null;
|
||||
variables.value = {};
|
||||
previewHtml.value = '';
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
loading,
|
||||
error,
|
||||
variables,
|
||||
selectedTemplate,
|
||||
previewHtml,
|
||||
generatedDocs,
|
||||
|
||||
// Actions
|
||||
loadVariables,
|
||||
selectTemplate,
|
||||
updatePreview,
|
||||
setVariable,
|
||||
generateAndSave,
|
||||
downloadOnly,
|
||||
printDocument,
|
||||
fetchGeneratedDocs,
|
||||
reset
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/composables/useDocumentTemplates.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
listTemplates,
|
||||
listAllTemplates,
|
||||
getTemplate,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
extractVariablesFromHtml,
|
||||
TEMPLATE_VARIABLES
|
||||
} from '@/services/DocumentTemplates.service';
|
||||
|
||||
// ── Composable ──────────────────────────────────────────────
|
||||
|
||||
export function useDocumentTemplates() {
|
||||
const templates = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const currentTemplate = ref(null);
|
||||
|
||||
// ── Tipos de template (para selects) ────────────────────
|
||||
|
||||
const TIPOS_TEMPLATE = [
|
||||
{ value: 'declaracao_comparecimento', label: 'Declaração de comparecimento' },
|
||||
{ value: 'atestado_psicologico', label: 'Atestado psicológico' },
|
||||
{ value: 'relatorio_acompanhamento', label: 'Relatório de acompanhamento' },
|
||||
{ value: 'recibo_pagamento', label: 'Recibo de pagamento' },
|
||||
{ value: 'termo_consentimento', label: 'Termo de consentimento (TCLE)' },
|
||||
{ value: 'encaminhamento', label: 'Encaminhamento' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
];
|
||||
|
||||
// ── Computed ────────────────────────────────────────────
|
||||
|
||||
const globalTemplates = computed(() =>
|
||||
templates.value.filter(t => t.is_global)
|
||||
);
|
||||
|
||||
const tenantTemplates = computed(() =>
|
||||
templates.value.filter(t => !t.is_global)
|
||||
);
|
||||
|
||||
const activeTemplates = computed(() =>
|
||||
templates.value.filter(t => t.ativo)
|
||||
);
|
||||
|
||||
// ── Variaveis agrupadas (para dropdown no editor) ───────
|
||||
|
||||
const variablesGrouped = computed(() => {
|
||||
const groups = {};
|
||||
for (const v of TEMPLATE_VARIABLES) {
|
||||
if (!groups[v.grupo]) groups[v.grupo] = [];
|
||||
groups[v.grupo].push(v);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// ── Carregar ────────────────────────────────────────────
|
||||
|
||||
async function fetchTemplates(includeInactive = false) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
templates.value = includeInactive
|
||||
? await listAllTemplates()
|
||||
: await listTemplates();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar templates.';
|
||||
templates.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplate(id) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
currentTemplate.value = await getTemplate(id);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar template.';
|
||||
currentTemplate.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────
|
||||
|
||||
async function create(payload) {
|
||||
const created = await createTemplate(payload);
|
||||
templates.value.unshift(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function update(id, payload) {
|
||||
const updated = await updateTemplate(id, payload);
|
||||
const idx = templates.value.findIndex(t => t.id === id);
|
||||
if (idx >= 0) templates.value[idx] = updated;
|
||||
if (currentTemplate.value?.id === id) currentTemplate.value = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await deleteTemplate(id);
|
||||
templates.value = templates.value.filter(t => t.id !== id);
|
||||
}
|
||||
|
||||
async function duplicate(id) {
|
||||
const copy = await duplicateTemplate(id);
|
||||
templates.value.unshift(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ── Extrair variaveis do HTML ───────────────────────────
|
||||
|
||||
function extractVariables(html) {
|
||||
return extractVariablesFromHtml(html);
|
||||
}
|
||||
|
||||
// ── Preview com dados ficticios ─────────────────────────
|
||||
|
||||
const SAMPLE_DATA = {
|
||||
paciente_nome: 'Maria Silva Santos',
|
||||
paciente_nome_social: 'Maria Santos',
|
||||
paciente_cpf: '123.456.789-00',
|
||||
paciente_data_nascimento: '15/03/1990',
|
||||
paciente_telefone: '(16) 99999-0000',
|
||||
paciente_email: 'maria@exemplo.com',
|
||||
paciente_endereco: 'Rua das Flores, 123, Centro, São Carlos/SP',
|
||||
data_sessao: '28/03/2026',
|
||||
hora_inicio: '14:00',
|
||||
hora_fim: '14:50',
|
||||
modalidade: 'Presencial',
|
||||
terapeuta_nome: 'Dr. João Oliveira',
|
||||
terapeuta_crp: '06/12345',
|
||||
terapeuta_email: 'joao@clinica.com',
|
||||
terapeuta_telefone: '(16) 3333-0000',
|
||||
clinica_nome: 'Clínica Exemplo',
|
||||
clinica_endereco: 'Av. São Carlos, 500, Centro, São Carlos/SP',
|
||||
clinica_telefone: '(16) 3333-1111',
|
||||
clinica_cnpj: '12.345.678/0001-00',
|
||||
valor: 'R$ 200,00',
|
||||
valor_extenso: 'duzentos reais',
|
||||
forma_pagamento: 'PIX',
|
||||
data_atual: new Date().toLocaleDateString('pt-BR'),
|
||||
data_atual_extenso: formatDateExtenso(new Date()),
|
||||
cidade_estado: 'São Carlos/SP'
|
||||
};
|
||||
|
||||
function formatDateExtenso(date) {
|
||||
const meses = [
|
||||
'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
|
||||
'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'
|
||||
];
|
||||
return `${date.getDate()} de ${meses[date.getMonth()]} de ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
function previewHtml(html) {
|
||||
return String(html || '').replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return SAMPLE_DATA[key] !== undefined
|
||||
? `<span style="background:#fef3c7;padding:1px 4px;border-radius:3px;">${SAMPLE_DATA[key]}</span>`
|
||||
: `<span style="background:#fee2e2;padding:1px 4px;border-radius:3px;">${match}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
templates,
|
||||
loading,
|
||||
error,
|
||||
currentTemplate,
|
||||
|
||||
// Constants
|
||||
TIPOS_TEMPLATE,
|
||||
TEMPLATE_VARIABLES,
|
||||
SAMPLE_DATA,
|
||||
|
||||
// Computed
|
||||
globalTemplates,
|
||||
tenantTemplates,
|
||||
activeTemplates,
|
||||
variablesGrouped,
|
||||
|
||||
// Actions
|
||||
fetchTemplates,
|
||||
fetchTemplate,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
duplicate,
|
||||
extractVariables,
|
||||
previewHtml
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/documents/composables/useDocuments.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import {
|
||||
listDocuments,
|
||||
listAllDocuments,
|
||||
uploadDocument,
|
||||
updateDocument,
|
||||
softDeleteDocument,
|
||||
restoreDocument,
|
||||
getDownloadUrl,
|
||||
getUsedTags
|
||||
} from '@/services/Documents.service';
|
||||
import { logAccess } from '@/services/DocumentAuditLog.service';
|
||||
|
||||
// ── Composable ──────────────────────────────────────────────
|
||||
|
||||
export function useDocuments(patientId = null) {
|
||||
const documents = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const usedTags = ref([]);
|
||||
|
||||
// Filtros reativos
|
||||
const filters = ref({
|
||||
tipo_documento: null,
|
||||
categoria: null,
|
||||
tag: null,
|
||||
search: ''
|
||||
});
|
||||
|
||||
// ── Computed: stats rapidos ─────────────────────────────
|
||||
|
||||
const stats = computed(() => {
|
||||
const docs = documents.value;
|
||||
const total = docs.length;
|
||||
const porTipo = {};
|
||||
const pendentesRevisao = docs.filter(d => d.status_revisao === 'pendente').length;
|
||||
|
||||
for (const d of docs) {
|
||||
const tipo = d.tipo_documento || 'outro';
|
||||
porTipo[tipo] = (porTipo[tipo] || 0) + 1;
|
||||
}
|
||||
|
||||
const tamanhoTotal = docs.reduce((sum, d) => sum + (d.tamanho_bytes || 0), 0);
|
||||
|
||||
return { total, porTipo, pendentesRevisao, tamanhoTotal };
|
||||
});
|
||||
|
||||
// ── Tipos de documento (para filtros) ───────────────────
|
||||
|
||||
const TIPOS_DOCUMENTO = [
|
||||
{ value: 'laudo', label: 'Laudo' },
|
||||
{ value: 'receita', label: 'Receita' },
|
||||
{ value: 'exame', label: 'Exame' },
|
||||
{ value: 'termo_assinado', label: 'Termo assinado' },
|
||||
{ value: 'relatorio_externo', label: 'Relatório externo' },
|
||||
{ value: 'identidade', label: 'Identidade' },
|
||||
{ value: 'convenio', label: 'Convênio' },
|
||||
{ value: 'declaracao', label: 'Declaração' },
|
||||
{ value: 'atestado', label: 'Atestado' },
|
||||
{ value: 'recibo', label: 'Recibo' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
];
|
||||
|
||||
// ── Carregar documentos ─────────────────────────────────
|
||||
|
||||
async function fetchDocuments() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const activeFilters = {};
|
||||
if (filters.value.tipo_documento) activeFilters.tipo_documento = filters.value.tipo_documento;
|
||||
if (filters.value.categoria) activeFilters.categoria = filters.value.categoria;
|
||||
if (filters.value.tag) activeFilters.tag = filters.value.tag;
|
||||
if (filters.value.search) activeFilters.search = filters.value.search;
|
||||
|
||||
const pid = typeof patientId === 'function' ? patientId() : patientId;
|
||||
if (pid) {
|
||||
documents.value = await listDocuments(pid, activeFilters);
|
||||
} else {
|
||||
documents.value = await listAllDocuments(activeFilters);
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar documentos.';
|
||||
documents.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────
|
||||
|
||||
async function upload(file, targetPatientId, meta = {}) {
|
||||
const pid = targetPatientId || (typeof patientId === 'function' ? patientId() : patientId);
|
||||
if (!pid) throw new Error('Paciente não informado.');
|
||||
|
||||
const doc = await uploadDocument(file, pid, meta);
|
||||
documents.value.unshift(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────
|
||||
|
||||
async function update(id, payload) {
|
||||
const updated = await updateDocument(id, payload);
|
||||
const idx = documents.value.findIndex(d => d.id === id);
|
||||
if (idx >= 0) documents.value[idx] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Soft delete ─────────────────────────────────────────
|
||||
|
||||
async function remove(id) {
|
||||
await softDeleteDocument(id);
|
||||
documents.value = documents.value.filter(d => d.id !== id);
|
||||
}
|
||||
|
||||
// ── Restore ─────────────────────────────────────────────
|
||||
|
||||
async function restore(id) {
|
||||
await restoreDocument(id);
|
||||
await fetchDocuments();
|
||||
}
|
||||
|
||||
// ── Download com auditoria ──────────────────────────────
|
||||
|
||||
async function download(doc) {
|
||||
const bucket = doc.storage_bucket || undefined;
|
||||
const url = await getDownloadUrl(doc.bucket_path, 60, bucket);
|
||||
logAccess(doc.id, 'baixou');
|
||||
|
||||
// Abrir download
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.nome_original || 'arquivo';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// ── Preview com auditoria ───────────────────────────────
|
||||
|
||||
async function getPreviewUrl(doc) {
|
||||
const bucket = doc.storage_bucket || undefined;
|
||||
const url = await getDownloadUrl(doc.bucket_path, 300, bucket);
|
||||
logAccess(doc.id, 'visualizou');
|
||||
return url;
|
||||
}
|
||||
|
||||
// ── Tags ────────────────────────────────────────────────
|
||||
|
||||
async function fetchUsedTags() {
|
||||
try {
|
||||
usedTags.value = await getUsedTags();
|
||||
} catch {
|
||||
usedTags.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Limpar filtros ──────────────────────────────────────
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = { tipo_documento: null, categoria: null, tag: null, search: '' };
|
||||
}
|
||||
|
||||
// ── Helper: formatar tamanho ────────────────────────────
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// ── Helper: icone por mime type ─────────────────────────
|
||||
|
||||
function mimeIcon(mimeType) {
|
||||
const m = String(mimeType || '');
|
||||
if (m.startsWith('image/')) return 'pi pi-image';
|
||||
if (m === 'application/pdf') return 'pi pi-file-pdf';
|
||||
if (m.includes('word') || m.includes('document')) return 'pi pi-file-word';
|
||||
if (m.includes('excel') || m.includes('sheet')) return 'pi pi-file-excel';
|
||||
if (m.startsWith('text/')) return 'pi pi-file';
|
||||
return 'pi pi-file';
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
usedTags,
|
||||
|
||||
// Computed
|
||||
stats,
|
||||
TIPOS_DOCUMENTO,
|
||||
|
||||
// Actions
|
||||
fetchDocuments,
|
||||
upload,
|
||||
update,
|
||||
remove,
|
||||
restore,
|
||||
download,
|
||||
getPreviewUrl,
|
||||
fetchUsedTags,
|
||||
clearFilters,
|
||||
|
||||
// Helpers
|
||||
formatSize,
|
||||
mimeIcon
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user