This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
@@ -1,24 +1,21 @@
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useTenantStore } from '@/stores/tenantStore'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import ConfirmDialog from 'primevue/confirmdialog'
import ProgressSpinner from 'primevue/progressspinner'
import Textarea from 'primevue/textarea'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import { brToISO, isoToBR } from '@/utils/dateBR'
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
const converting = ref(false)
const loading = ref(false)
@@ -227,7 +224,7 @@ function fmtDate (iso) {
return d.toLocaleString('pt-BR')
}
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
// converte nascimento para ISO date (YYYY-MM-DD)
function normalizeBirthToISO (v) {
if (!v) return null
const s = String(v).trim()
@@ -248,6 +245,34 @@ function normalizeBirthToISO (v) {
return `${yyyy}-${mm}-${dd}`
}
// -----------------------------
// Tenant + Responsible Member (para satisfazer trigger)
// -----------------------------
async function getTenantIdForConversion (item) {
// intake NÃO tem tenant_id hoje, então usamos o contexto
const fromStore =
tenantStore?.activeTenantId ||
tenantStore?.currentTenantId ||
tenantStore?.tenantId ||
tenantStore?.tenant?.id
return fromStore || null
}
async function getResponsibleMemberId (tenantId, userId) {
const { data, error } = await supabase
.from('tenant_members')
.select('id')
.eq('tenant_id', tenantId)
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
if (error) throw error
if (!data?.id) throw new Error('Responsible member not found')
return data.id
}
// -----------------------------
// Seções do modal
// -----------------------------
@@ -420,19 +445,19 @@ async function markRejected () {
}
// -----------------------------
// Converter
// Converter (com tenant_id + responsible_member_id)
// -----------------------------
async function convertToPatient () {
const item = dlg.value?.item
if (!item?.id) return
if (converting.value) return
// regra de negócio: só converte "new"
if (item.status !== 'new') {
// só bloqueia cadastros já convertidos
if (item.status === 'converted') {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Só é possível converter cadastros com status "Novo".',
detail: 'Este cadastro já foi convertido em paciente.',
life: 3000
})
return
@@ -447,19 +472,27 @@ async function convertToPatient () {
const ownerId = userData?.user?.id
if (!ownerId) throw new Error('Sessão inválida.')
const tenantId = await getTenantIdForConversion(item)
if (!tenantId) throw new Error('tenant_id is required')
const responsibleMemberId = await getResponsibleMemberId(tenantId, ownerId)
const cleanStr = (v) => {
const s = String(v ?? '').trim()
return s ? s : null
}
const digitsOnly = (v) => {
const d = String(v ?? '').replace(/\D/g, '')
return d ? d : null
}
// tenta reaproveitar avatar do intake (se vier url/path)
// tenta reaproveitar avatar do intake
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
const patientPayload = {
tenant_id: tenantId,
responsible_member_id: responsibleMemberId,
owner_id: ownerId,
// identificação/contato
@@ -471,7 +504,7 @@ async function convertToPatient () {
telefone_alternativo: digitsOnly(fTelAlt(item)),
// pessoais
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
data_nascimento: normalizeBirthToISO(fNasc(item)),
naturalidade: cleanStr(fNaturalidade(item)),
genero: cleanStr(fGenero(item)),
estado_civil: cleanStr(fEstadoCivil(item)),
@@ -520,6 +553,7 @@ async function convertToPatient () {
const patientId = created?.id
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
// ✅ intake é externo: não prenda por owner_id aqui
const { error: upErr } = await supabase
.from('patient_intake_requests')
.update({
@@ -528,7 +562,6 @@ async function convertToPatient () {
updated_at: new Date().toISOString()
})
.eq('id', item.id)
.eq('owner_id', ownerId)
if (upErr) throw upErr
@@ -537,6 +570,7 @@ async function convertToPatient () {
dlg.value.open = false
await fetchIntakes()
} catch (err) {
console.error(err)
toast.add({
severity: 'error',
summary: 'Falha ao converter',
@@ -557,135 +591,125 @@ const totals = computed(() => {
return { total, nNew, nConv, nRej }
})
onMounted(fetchIntakes)
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const recMobileMenuRef = ref(null)
const recSearchDlgOpen = ref(false)
const recMobileMenuItems = computed(() => [
{ label: 'Buscar', icon: 'pi pi-search', command: () => { recSearchDlgOpen.value = true } },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchIntakes() }
])
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
fetchIntakes()
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<div class="p-4">
<ConfirmDialog />
<Toast />
<ConfirmDialog />
<!-- HEADER -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative px-5 py-5">
<!-- faixa de cor -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
</div>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="rec-sentinel" />
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-lg"></i>
</div>
<!-- Hero sticky -->
<div ref="headerEl" class="rec-hero mx-3 md:mx-5 mb-4" :class="{ 'rec-hero--stuck': headerStuck }">
<div class="rec-hero__blobs" aria-hidden="true">
<div class="rec-hero__blob rec-hero__blob--1" />
<div class="rec-hero__blob rec-hero__blob--2" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
<Tag :value="`${totals.total}`" severity="secondary" />
</div>
<div class="text-color-secondary mt-1">
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
</div>
</div>
</div>
<!-- filtros -->
<div class="mt-4 flex flex-wrap gap-2">
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'new'"
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
@click="toggleStatusFilter('new')"
>
<span class="flex items-center gap-2">
<i class="pi pi-sparkles" />
Novos: <b>{{ totals.nNew }}</b>
</span>
</Button>
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'converted'"
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
@click="toggleStatusFilter('converted')"
>
<span class="flex items-center gap-2">
<i class="pi pi-check" />
Convertidos: <b>{{ totals.nConv }}</b>
</span>
</Button>
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'rejected'"
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
@click="toggleStatusFilter('rejected')"
>
<span class="flex items-center gap-2">
<i class="pi pi-times" />
Rejeitados: <b>{{ totals.nRej }}</b>
</span>
</Button>
<Button
v-if="statusFilter"
type="button"
class="!rounded-full"
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar filtro"
@click="statusFilter = ''"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
<span class="p-input-icon-left w-full sm:w-[360px]">
<InputText
v-model="q"
class="w-full"
placeholder="Buscar por nome, e-mail ou telefone…"
/>
</span>
<div class="flex gap-2">
<Button
icon="pi pi-refresh"
label="Atualizar"
severity="secondary"
outlined
:loading="loading"
@click="fetchIntakes"
/>
</div>
<!-- Linha 1 -->
<div class="rec-hero__row1">
<div class="rec-hero__brand">
<div class="rec-hero__icon"><i class="pi pi-inbox text-lg" /></div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="rec-hero__title">Cadastros recebidos</div>
<Tag :value="`${totals.total}`" severity="secondary" />
</div>
<div class="rec-hero__sub">Pré-cadastros externos para avaliar e converter em pacientes</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchIntakes" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => recMobileMenuRef.toggle(e)" />
<Menu ref="recMobileMenuRef" :model="recMobileMenuItems" :popup="true" />
</div>
</div>
<!-- TABLE -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-10">
<ProgressSpinner style="width: 38px; height: 38px" />
<!-- Divisor -->
<Divider class="rec-hero__divider my-2" />
<!-- Linha 2: filtros de status + busca -->
<div class="rec-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'new'" :severity="statusFilter === 'new' ? 'info' : 'secondary'" @click="toggleStatusFilter('new')">
<span class="flex items-center gap-1.5"><i class="pi pi-sparkles text-xs" /> Novos: <b>{{ totals.nNew }}</b></span>
</Button>
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'converted'" :severity="statusFilter === 'converted' ? 'success' : 'secondary'" @click="toggleStatusFilter('converted')">
<span class="flex items-center gap-1.5"><i class="pi pi-check text-xs" /> Convertidos: <b>{{ totals.nConv }}</b></span>
</Button>
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'rejected'" :severity="statusFilter === 'rejected' ? 'danger' : 'secondary'" @click="toggleStatusFilter('rejected')">
<span class="flex items-center gap-1.5"><i class="pi pi-times text-xs" /> Rejeitados: <b>{{ totals.nRej }}</b></span>
</Button>
<Button v-if="statusFilter" type="button" size="small" class="!rounded-full" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" @click="statusFilter = ''" />
</div>
<DataTable
v-else
:value="filteredRows"
dataKey="id"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
responsiveLayout="scroll"
stripedRows
class="!border-0"
>
<InputGroup class="w-72 shrink-0">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" :disabled="loading" />
<Button v-if="q" icon="pi pi-trash" severity="danger" title="Limpar" @click="q = ''" />
</InputGroup>
</div>
</div>
<!-- Dialog busca mobile -->
<Dialog v-model:visible="recSearchDlgOpen" modal :draggable="false" header="Buscar cadastro" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" autofocus />
<Button v-if="q" icon="pi pi-trash" severity="danger" @click="q = ''" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="recSearchDlgOpen = false" />
</template>
</Dialog>
<!-- TABLE desktop (md+) -->
<div class="hidden md:block mx-3 md:mx-5 mb-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<DataTable
:value="filteredRows"
:loading="loading"
dataKey="id"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
stripedRows
class="!border-0"
>
<Column header="Status" style="width: 10rem">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
@@ -734,13 +758,67 @@ onMounted(fetchIntakes)
</Column>
<template #empty>
<div class="text-color-secondary py-6 text-center">
Nenhum cadastro encontrado.
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-xl" />
</div>
<div class="font-semibold">Nenhum cadastro encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
</div>
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
</div>
</div>
</template>
</DataTable>
</div>
<!-- TABLE mobile cards (<md) -->
<div class="md:hidden mx-3 mb-5">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<div v-else-if="filteredRows.length === 0" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-xl" />
</div>
<div class="font-semibold">Nenhum cadastro encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
</div>
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
</div>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="row in filteredRows"
:key="row.id"
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<div class="flex items-center gap-3">
<Avatar v-if="avatarUrl(row)" :image="avatarUrl(row)" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{{ fNome(row) || '—' }}</div>
<div class="text-sm text-color-secondary truncate">{{ fEmail(row) || '—' }}</div>
</div>
<Tag :value="statusLabel(row.status)" :severity="statusSeverity(row.status)" />
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<div class="text-sm text-color-secondary flex flex-col gap-0.5">
<span>{{ fmtPhoneBR(fTel(row)) }}</span>
<span>{{ fmtDate(row.created_at) }}</span>
</div>
<Button icon="pi pi-eye" label="Ver" severity="secondary" outlined size="small" @click="openDetails(row)" />
</div>
</div>
</div>
</div>
<!-- MODAL -->
<Dialog
v-model:visible="dlg.open"
@@ -748,6 +826,7 @@ onMounted(fetchIntakes)
:header="null"
:style="{ width: 'min(940px, 96vw)' }"
:contentStyle="{ padding: 0 }"
:draggable="false"
@hide="closeDlg"
>
<div v-if="dlg.item" class="relative">
@@ -878,5 +957,49 @@ onMounted(fetchIntakes)
</div>
</div>
</Dialog>
</div>
</template>
</template>
<style scoped>
.rec-sentinel { height: 1px; }
.rec-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.rec-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.rec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.rec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.rec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.rec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.rec-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
.rec-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.rec-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.rec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.rec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.rec-hero__row2 {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
}
@media (max-width: 767px) {
.rec-hero__divider,
.rec-hero__row2 { display: none; }
}
</style>