a7f6bcbe66
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
.eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
(singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados
Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
(gerenciam defaults do sistema / views cross-tenant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
394 lines
17 KiB
Vue
394 lines
17 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| 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 { tenantDb } from '@/lib/supabase/tenantClient';
|
|
import {
|
|
createSignatureRequests,
|
|
listSignatures,
|
|
getSignatureStatus
|
|
} from '@/services/DocumentSignatures.service'
|
|
import { createShareLink, buildShareUrl } from '@/services/DocumentShareLinks.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([])
|
|
|
|
// Geracao de share link p/ assinatura via portal/whatsapp
|
|
const generateLink = ref(true)
|
|
const linkExpiracaoHoras = ref(168) // 7 dias default
|
|
const generatedShareUrl = 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 tenantDb().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 = []
|
|
generatedShareUrl.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
|
|
}
|
|
}
|
|
})
|
|
|
|
function copyShareUrl() {
|
|
if (!generatedShareUrl.value) return
|
|
navigator.clipboard.writeText(generatedShareUrl.value)
|
|
.then(() => toast.add({ severity: 'success', summary: 'Link copiado', life: 1800 }))
|
|
.catch(() => toast.add({ severity: 'warn', summary: 'Falha ao copiar', detail: 'Copie manualmente.', life: 2200 }))
|
|
}
|
|
|
|
// ── 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)
|
|
|
|
// Gera share link público quando habilitado — o paciente abre /shared/document/:token
|
|
// e assina via fluxo público (RPC sign_document_by_token captura IP/UA server-side).
|
|
if (generateLink.value) {
|
|
try {
|
|
const link = await createShareLink(props.doc.id, {
|
|
expiracaoHoras: Number(linkExpiracaoHoras.value) || 168,
|
|
usosMax: Math.max(signatarios.value.length * 3, 5)
|
|
})
|
|
generatedShareUrl.value = buildShareUrl(link.token)
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Solicitação criada',
|
|
detail: `${result.length} signatário(s). Link de assinatura gerado.`,
|
|
life: 3500
|
|
})
|
|
} catch (linkErr) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Signatários criados, mas falhou o link',
|
|
detail: linkErr?.message || 'Tente gerar o link na ação "Compartilhar".',
|
|
life: 4500
|
|
})
|
|
}
|
|
} else {
|
|
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
|
|
}
|
|
|
|
emit('requested', { signatures: result, shareUrl: generatedShareUrl.value })
|
|
|
|
// Mantém dialog aberto se gerou link — pra terapeuta copiar.
|
|
// Fecha automaticamente se não gerou link.
|
|
if (!generatedShareUrl.value) 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>
|
|
|
|
<!-- Toggle: gerar link público -->
|
|
<div class="flex items-start gap-3 p-3 rounded-lg border border-blue-200 bg-blue-50/40">
|
|
<Checkbox v-model="generateLink" :binary="true" inputId="cb-gen-link" class="mt-0.5" />
|
|
<div class="flex-1">
|
|
<label for="cb-gen-link" class="text-sm font-medium text-[var(--text-color)] cursor-pointer">
|
|
Gerar link público para assinatura
|
|
</label>
|
|
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
|
Cria um link em <code>/shared/document/<token></code> pra enviar via WhatsApp, e-mail ou copiar. O paciente assina sem precisar logar (IP, navegador e timestamp são registrados server-side).
|
|
</div>
|
|
<div v-if="generateLink" class="mt-2 flex items-center gap-2">
|
|
<label class="text-xs text-[var(--text-color-secondary)]">Validade:</label>
|
|
<Select
|
|
v-model="linkExpiracaoHoras"
|
|
:options="[
|
|
{ value: 24, label: '24 horas' },
|
|
{ value: 72, label: '3 dias' },
|
|
{ value: 168, label: '7 dias' },
|
|
{ value: 720, label: '30 dias' }
|
|
]"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
class="!text-xs w-32"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Link gerado (após submit) -->
|
|
<div v-if="generatedShareUrl" class="p-3 rounded-lg border border-emerald-200 bg-emerald-50/40">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<i class="pi pi-link text-emerald-600" />
|
|
<div class="text-sm font-medium text-emerald-800">Link de assinatura gerado</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<InputText :modelValue="generatedShareUrl" readonly class="w-full !text-xs" />
|
|
<Button icon="pi pi-copy" size="small" class="!h-8 shrink-0" v-tooltip.top="'Copiar link'" @click="copyShareUrl" />
|
|
</div>
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1.5">
|
|
Envie este link para o(a) paciente. Eles podem assinar diretamente sem precisar criar conta.
|
|
</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>
|