Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions
@@ -0,0 +1,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>