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
@@ -31,6 +31,7 @@ import Select from 'primevue/select';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { digitsOnly, isValidEmail, toISODate, generateCPF, fmtCPF } from '@/utils/validators';
const route = useRoute();
const toast = useToast();
@@ -48,37 +49,6 @@ function cleanStr(v) {
const s = String(v ?? '').trim();
return s ? s : null;
}
function digitsOnly(v) {
const d = String(v ?? '').replace(/\D/g, '');
return d ? d : null;
}
function isValidEmail(v) {
const s = String(v ?? '').trim();
if (!s) return false;
return /.+@.+\..+/.test(s);
}
// DD-MM-YYYY -> YYYY-MM-DD
function parseDDMMYYYY(s) {
const str = String(s || '').trim();
const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(str);
if (!m) return null;
const dd = Number(m[1]);
const mm = Number(m[2]);
const yyyy = Number(m[3]);
const dt = new Date(yyyy, mm - 1, dd);
if (Number.isNaN(dt.getTime())) return null;
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return null;
return dt;
}
function toISODateFromDDMMYYYY(s) {
const dt = parseDDMMYYYY(s);
if (!dt) return null;
const yyyy = String(dt.getFullYear()).padStart(4, '0');
const mm = String(dt.getMonth() + 1).padStart(2, '0');
const dd = String(dt.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
// ------------------------------------------------------
// Mock fill (Preencher tudo)
@@ -112,18 +82,8 @@ function randomPhoneBR() {
return `(${ddd}) ${p1}-${p2}`;
}
function generateCPF() {
const n = Array.from({ length: 9 }, () => randInt(0, 9));
const calcDV = (base) => {
let sum = 0;
for (let i = 0; i < base.length; i++) sum += base[i] * (base.length + 1 - i);
const mod = sum % 11;
return mod < 2 ? 0 : 11 - mod;
};
const d1 = calcDV(n);
const d2 = calcDV([...n, d1]);
const cpf = [...n, d1, d2].join('');
return cpf.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, '$1.$2.$3-$4');
function generateCPFFormatted() {
return fmtCPF(generateCPF());
}
function generateRG() {
@@ -170,7 +130,7 @@ function preencherMock() {
form.telefone = randomPhoneBR();
form.telefone_alternativo = maybe(0.35) ? randomPhoneBR() : '';
form.cpf = generateCPF();
form.cpf = generateCPFFormatted();
form.rg = generateRG();
form.observacoes = maybe(0.5) ? 'Cadastro realizado via link externo.' : 'Tenho disponibilidade no período da noite.';
@@ -199,7 +159,7 @@ function preencherMock() {
const temResponsavel = maybe(0.35);
form.nome_responsavel = temResponsavel ? `${pick(first)} ${pick(last)} ${pick(last)}` : '';
form.telefone_responsavel = temResponsavel ? randomPhoneBR() : '';
form.cpf_responsavel = temResponsavel ? generateCPF() : '';
form.cpf_responsavel = temResponsavel ? generateCPFFormatted() : '';
form.observacao_responsavel = temResponsavel ? 'Responsável ciente e de acordo com o cadastro.' : '';
form.cobranca_no_responsavel = temResponsavel ? maybe(0.5) : false;
@@ -494,7 +454,7 @@ async function enviar() {
return;
}
const isoBirth = toISODateFromDDMMYYYY(form.data_nascimento);
const isoBirth = toISODate(form.data_nascimento);
if (cleanStr(form.data_nascimento) && !isoBirth) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Data de nascimento inválida (use DD-MM-AAAA).', life: 2500 });
openPanel(0);
@@ -0,0 +1,138 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/views/pages/public/SharedDocumentPage.vue
| Pagina publica para visualizar documento compartilhado via link temporario.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { validateShareToken } from '@/services/DocumentShareLinks.service'
import { supabase } from '@/lib/supabase/client'
const route = useRoute()
const loading = ref(true)
const error = ref(null)
const doc = ref(null)
const previewUrl = ref('')
onMounted(async () => {
const token = route.params.token
if (!token) {
error.value = 'Link inválido.'
loading.value = false
return
}
try {
const result = await validateShareToken(token)
if (!result?.document) {
error.value = 'Este link expirou, atingiu o limite de acessos ou é inválido.'
loading.value = false
return
}
doc.value = result.document
// Gerar URL assinada para download/visualizacao
const bucket = result.document.storage_bucket || 'documents'
const { data, error: storageErr } = await supabase.storage
.from(bucket)
.createSignedUrl(result.document.bucket_path, 300) // 5 min
if (storageErr) throw storageErr
previewUrl.value = data?.signedUrl || ''
} catch (e) {
error.value = e?.message || 'Erro ao acessar o documento.'
} finally {
loading.value = false
}
})
function downloadFile() {
if (!previewUrl.value) return
const a = document.createElement('a')
a.href = previewUrl.value
a.download = doc.value?.nome_original || 'documento'
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const isPdf = () => doc.value?.mime_type === 'application/pdf'
const isImage = () => String(doc.value?.mime_type || '').startsWith('image/')
</script>
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div class="w-full max-w-3xl">
<!-- Loading -->
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
<i class="pi pi-spinner pi-spin text-3xl text-gray-400 mb-3" />
<p class="text-sm text-gray-500">Validando link...</p>
</div>
<!-- Erro -->
<div v-else-if="error" class="flex flex-col items-center justify-center py-20 text-center">
<div class="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center mb-4">
<i class="pi pi-times-circle text-3xl text-red-400" />
</div>
<h2 class="text-lg font-semibold text-gray-700 mb-2">Documento indisponível</h2>
<p class="text-sm text-gray-500 max-w-md">{{ error }}</p>
</div>
<!-- Documento -->
<div v-else class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-200">
<div class="flex items-center gap-3 min-w-0">
<span class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center">
<i :class="isPdf() ? 'pi pi-file-pdf text-red-500' : isImage() ? 'pi pi-image text-blue-500' : 'pi pi-file text-gray-500'" />
</span>
<div class="min-w-0">
<div class="text-sm font-medium text-gray-800 truncate">{{ doc?.nome_original }}</div>
<div class="text-xs text-gray-400">Documento compartilhado via AgênciaPSI</div>
</div>
</div>
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
@click="downloadFile"
>
<i class="pi pi-download text-xs" />
Baixar
</button>
</div>
<!-- Preview -->
<div class="bg-gray-100">
<iframe
v-if="isPdf()"
:src="previewUrl"
class="w-full border-0"
style="height: 80vh;"
/>
<div v-else-if="isImage()" class="flex items-center justify-center p-6">
<img :src="previewUrl" :alt="doc?.nome_original" class="max-w-full max-h-[80vh] object-contain rounded" />
</div>
<div v-else class="flex flex-col items-center justify-center py-20 text-gray-400">
<i class="pi pi-file text-4xl mb-3" />
<p class="text-sm">Pré-visualização não disponível para este tipo de arquivo.</p>
<p class="text-xs mt-1">Clique em "Baixar" para acessar o documento.</p>
</div>
</div>
<!-- Footer -->
<div class="p-3 text-center text-xs text-gray-400 border-t border-gray-200">
Compartilhado com segurança via AgênciaPSI
</div>
</div>
</div>
</div>
</template>