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,14 +1,16 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const toast = useToast()
@@ -16,8 +18,17 @@ const confirm = useConfirm()
const tenantStore = useTenantStore()
/**
* ✅ NOTAS IMPORTANTES DO AJUSTE
* - Corrige o 404 (admin usa /pacientes..., therapist usa /patients...).
* - Depois de criar (insert), faz upload do avatar usando o ID recém-criado.
* - Se bucket não for público, troca para signed URL automaticamente (fallback).
*/
// ------------------------------------------------------
// Tenant helpers
// ------------------------------------------------------
async function getCurrentTenantId () {
// ajuste para o nome real no seu store
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
}
@@ -105,6 +116,48 @@ onBeforeUnmount(() => {
const patientId = computed(() => String(route.params?.id || '').trim() || null)
const isEdit = computed(() => !!patientId.value)
// ------------------------------------------------------
// ✅ FIX 404: base por área + rotas reais (admin: /pacientes | therapist: /patients)
// ------------------------------------------------------
function getAreaKey () {
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
return seg === 'therapist' ? 'therapist' : 'admin'
}
function getPatientsRoutes () {
const area = getAreaKey()
if (area === 'therapist') {
return {
listName: 'therapist-patients',
editName: 'therapist-patients-edit',
listPath: '/therapist/patients',
editPath: (id) => `/therapist/patients/cadastro/${id}`
}
}
return {
listName: 'admin-pacientes',
editName: 'admin-pacientes-cadastro-edit',
listPath: '/admin/pacientes',
editPath: (id) => `/admin/pacientes/cadastro/${id}`
}
}
async function safePush (toNameObj, fallbackPath) {
try {
const r = router.resolve(toNameObj)
if (r?.matched?.length) return router.push(toNameObj)
} catch (_) {}
return router.push(fallbackPath)
}
function goBack () {
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
}
// ------------------------------------------------------
// Avatar state (TEM que existir no setup)
// ------------------------------------------------------
@@ -112,7 +165,7 @@ const avatarFile = ref(null)
const avatarPreviewUrl = ref('')
const avatarUploading = ref(false)
const AVATAR_BUCKET = 'avatars' // confirme o nome do bucket no Supabase
const AVATAR_BUCKET = 'avatars'
function isImageFile (file) {
return !!file && typeof file.type === 'string' && file.type.startsWith('image/')
@@ -149,6 +202,24 @@ function onAvatarPicked (ev) {
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em “Salvar” para enviar.', life: 2500 })
}
// ✅ Gera URL pública OU signed URL (se o bucket for privado)
async function getReadableAvatarUrl (path) {
// tenta público primeiro
try {
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const publicUrl = pub?.publicUrl || null
if (publicUrl) return publicUrl
} catch (_) {}
// fallback: signed (bucket privado)
const { data, error } = await supabase.storage
.from(AVATAR_BUCKET)
.createSignedUrl(path, 60 * 60 * 24 * 7) // 7 dias
if (error) throw error
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
return data.signedUrl
}
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (!ownerId) throw new Error('ownerId ausente.')
if (!patientId) throw new Error('patientId ausente.')
@@ -171,15 +242,12 @@ async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (upErr) throw upErr
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const publicUrl = pub?.publicUrl || null
if (!publicUrl) throw new Error('Não consegui gerar URL pública do avatar.')
return { publicUrl, path }
const readableUrl = await getReadableAvatarUrl(path)
return { publicUrl: readableUrl, path }
}
async function maybeUploadAvatar (ownerId, id) {
if (!avatarFile.value) return
if (!avatarFile.value) return null
avatarUploading.value = true
try {
@@ -189,45 +257,40 @@ async function maybeUploadAvatar (ownerId, id) {
file: avatarFile.value
})
// 1) atualiza UI IMEDIATAMENTE (não deixa “sumir”)
// UI
form.value.avatar_url = publicUrl
// 2) grava no banco
await updatePatient(id, { avatar_url: publicUrl })
// 3) limpa o arquivo selecionado
avatarFile.value = null
// 4) se o preview era blob, pode revogar
// MAS NÃO zere o avatarPreviewUrl se o template depende dele
// => aqui vamos só revogar e então setar para a própria URL pública.
revokePreview()
avatarPreviewUrl.value = publicUrl
// DB
await updatePatient(id, { avatar_url: publicUrl })
return publicUrl
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Avatar',
detail: e?.message || 'Falha ao enviar avatar.',
life: 4000
life: 4500
})
return null
} finally {
avatarUploading.value = false
}
}
// ------------------------------------------------------
// Form state (PT-BR)
// Form state
// ------------------------------------------------------
function resetForm () {
return {
// Sessão 1 — pessoais
nome_completo: '',
telefone: '',
email_principal: '',
email_alternativo: '',
telefone_alternativo: '',
data_nascimento: '', // ✅ SEMPRE DD-MM-AAAA (hífen)
data_nascimento: '',
genero: '',
estado_civil: '',
cpf: '',
@@ -237,7 +300,6 @@ function resetForm () {
onde_nos_conheceu: '',
encaminhado_por: '',
// Sessão 2 — endereço
cep: '',
pais: 'Brasil',
cidade: '',
@@ -247,24 +309,19 @@ function resetForm () {
bairro: '',
complemento: '',
// Sessão 3 — adicionais
escolaridade: '',
profissao: '',
nome_parente: '',
grau_parentesco: '',
telefone_parente: '',
// Sessão 4 — responsável
nome_responsavel: '',
cpf_responsavel: '',
telefone_responsavel: '',
observacao_responsavel: '',
cobranca_no_responsavel: false,
// Sessão 5 — internos
notas_internas: '',
// Avatar
avatar_url: ''
}
}
@@ -331,7 +388,6 @@ function toISODateFromDDMMYYYY (s) {
return `${yyyy}-${mm}-${dd}`
}
// banco (YYYY-MM-DD ou ISO) -> form (DD-MM-YYYY)
function isoToDDMMYYYY (value) {
if (!value) return ''
const s = String(value).trim()
@@ -407,7 +463,6 @@ function mapDbToForm (p) {
cobranca_no_responsavel: !!p.cobranca_no_responsavel,
notas_internas: p.notas_internas ?? '',
avatar_url: p.avatar_url ?? ''
}
}
@@ -430,8 +485,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id',
'tenant_id',
'responsible_member_id',
// Sessão 1
'nome_completo',
'telefone',
'email_principal',
@@ -446,8 +499,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'observacoes',
'onde_nos_conheceu',
'encaminhado_por',
// Sessão 2
'pais',
'cep',
'cidade',
@@ -456,25 +507,17 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'numero',
'bairro',
'complemento',
// Sessão 3
'escolaridade',
'profissao',
'nome_parente',
'grau_parentesco',
'telefone_parente',
// Sessão 4
'nome_responsavel',
'cpf_responsavel',
'telefone_responsavel',
'observacao_responsavel',
'cobranca_no_responsavel',
// Sessão 5
'notas_internas',
// Avatar
'avatar_url'
])
@@ -523,11 +566,10 @@ function sanitizePayload (raw, ownerId) {
cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
notas_internas: raw.notas_internas || null,
avatar_url: raw.avatar_url || null
}
// strings vazias -> null
// strings vazias -> null e trim
Object.keys(payload).forEach(k => {
if (payload[k] === '') payload[k] = null
if (typeof payload[k] === 'string') {
@@ -536,23 +578,19 @@ function sanitizePayload (raw, ownerId) {
}
})
// docs: só dígitos
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
// fones: só dígitos
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
// ✅ FIX CRÍTICO: DD-MM-YYYY -> YYYY-MM-DD
payload.data_nascimento = payload.data_nascimento
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
: null
// filtra
const filtrado = {}
Object.keys(payload).forEach(k => {
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
@@ -565,11 +603,7 @@ function sanitizePayload (raw, ownerId) {
// Supabase: lists / get / relations
// ------------------------------------------------------
async function listGroups () {
const probe = await supabase
.from('patient_groups')
.select('*')
.limit(1)
const probe = await supabase.from('patient_groups').select('*').limit(1)
if (probe.error) throw probe.error
const row = probe.data?.[0] || {}
@@ -582,13 +616,8 @@ async function listGroups () {
.select('id,nome,descricao,cor,is_system,is_active')
.eq('is_active', true)
.order('nome', { ascending: true })
if (error) throw error
return (data || []).map(g => ({
...g,
name: g.nome,
color: g.cor
}))
return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
}
if (hasEN) {
@@ -597,93 +626,42 @@ async function listGroups () {
.select('id,name,description,color,is_system,is_active')
.eq('is_active', true)
.order('name', { ascending: true })
if (error) throw error
return (data || []).map(g => ({
...g,
nome: g.name,
cor: g.color
}))
return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
}
const { data, error } = await supabase
.from('patient_groups')
.select('*')
.order('id', { ascending: true })
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
if (error) throw error
return data || []
}
async function listTags () {
// 1) Pega 1 registro sem order, só pra descobrir o schema real (sem 400)
const probe = await supabase
.from('patient_tags')
.select('*')
.limit(1)
const probe = await supabase.from('patient_tags').select('*').limit(1)
if (probe.error) throw probe.error
const row = probe.data?.[0] || {}
const hasEN = ('name' in row) || ('color' in row)
const hasPT = ('nome' in row) || ('cor' in row)
// 2) Se não tem nada, a tabela pode estar vazia.
// Ainda assim, precisamos decidir por qual coluna ordenar.
// Vamos descobrir colunas existentes via select de 0 rows (head) NÃO é suportado bem no client,
// então usamos uma estratégia safe:
// - tenta EN com order se faz sentido
// - senão PT
// - e por último sem order.
if (hasEN) {
const { data, error } = await supabase
.from('patient_tags')
.select('id,name,color')
.order('name', { ascending: true })
const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true })
if (error) throw error
return data || []
}
if (hasPT) {
const { data, error } = await supabase
.from('patient_tags')
.select('id,nome,cor')
.order('nome', { ascending: true })
const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true })
if (error) throw error
return (data || []).map(t => ({
...t,
name: t.nome,
color: t.cor
}))
return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor }))
}
// 3) fallback final: tabela vazia ou schema incomum
const { data, error } = await supabase
.from('patient_tags')
.select('*')
.order('id', { ascending: true })
const { data, error } = await supabase.from('patient_tags').select('*').order('id', { ascending: true })
if (error) throw error
return (data || []).map(t => ({
...t,
name: t.name ?? t.nome ?? '',
color: t.color ?? t.cor ?? null
}))
return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
}
async function getPatientById (id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.single()
const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
if (error) throw error
return data
}
@@ -708,11 +686,7 @@ async function getPatientRelations (id) {
}
async function createPatient (payload) {
const { data, error } = await supabase
.from('patients')
.insert(payload)
.select('id')
.single()
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
if (error) throw error
return data
}
@@ -720,17 +694,14 @@ async function createPatient (payload) {
async function updatePatient (id, payload) {
const { error } = await supabase
.from('patients')
.update({
...payload,
updated_at: new Date().toISOString()
})
.update({ ...payload, updated_at: new Date().toISOString() })
.eq('id', id)
if (error) throw error
}
// ------------------------------------------------------
// Relations
// Relations state
// ------------------------------------------------------
const groups = ref([])
const tags = ref([])
@@ -738,17 +709,11 @@ const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([])
async function replacePatientGroups (patient_id, groupId) {
const { error: delErr } = await supabase
.from('patient_group_patient')
.delete()
.eq('patient_id', patient_id)
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
if (delErr) throw delErr
if (!groupId) return
const { error: insErr } = await supabase
.from('patient_group_patient')
.insert({ patient_id, patient_group_id: groupId })
const { tenantId } = await resolveTenantContextOrFail()
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId })
if (insErr) throw insErr
}
@@ -765,15 +730,9 @@ async function replacePatientTags (patient_id, tagIds) {
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
if (!clean.length) return
const rows = clean.map(tag_id => ({
owner_id: ownerId,
patient_id,
tag_id
}))
const { error: insErr } = await supabase
.from('patient_patient_tag')
.insert(rows)
const { tenantId } = await resolveTenantContextOrFail()
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows)
if (insErr) throw insErr
}
@@ -808,19 +767,6 @@ const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// ------------------------------------------------------
// Route base (admin x therapist)
// ------------------------------------------------------
function getAreaBase () {
const seg = String(route.path || '').split('/')[1]
return seg === 'therapist' ? '/therapist' : '/admin'
}
function goBack () {
if (window.history.length > 1) router.back()
else router.push(`${getAreaBase()}/patients`)
}
// ------------------------------------------------------
// Fetch (load everything)
// ------------------------------------------------------
@@ -829,34 +775,32 @@ async function fetchAll () {
try {
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
if (gRes.status === 'fulfilled') {
groups.value = gRes.value || []
} else {
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
else {
groups.value = []
console.warn('[listGroups error]', gRes.reason)
toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 })
}
if (tRes.status === 'fulfilled') {
tags.value = tRes.value || []
} else {
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
else {
tags.value = []
console.warn('[listTags error]', tRes.reason)
toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 })
}
console.log('[groups]', groups.value.length, groups.value[0])
console.log('[tags]', tags.value.length, tags.value[0])
if (isEdit.value) {
const p = await getPatientById(patientId.value)
form.value = mapDbToForm(p)
// se já tinha avatar no banco, garante preview
avatarPreviewUrl.value = form.value.avatar_url || ''
const rel = await getPatientRelations(patientId.value)
grupoIdSelecionado.value = rel.groupIds?.[0] || null
tagIdsSelecionadas.value = rel.tagIds || []
} else {
form.value = resetForm()
//form.value = resetForm()
grupoIdSelecionado.value = null
tagIdsSelecionadas.value = []
avatarFile.value = null
@@ -872,19 +816,33 @@ async function fetchAll () {
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
// ------------------------------------------------------
// Tenant resolve (robusto)
// ------------------------------------------------------
async function resolveTenantContextOrFail () {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
const uid = authData?.user?.id
if (!uid) throw new Error('Sessão inválida.')
// 1) tenta pelo store
const storeTid = await getCurrentTenantId()
if (storeTid) {
try {
const mid = await getCurrentMemberId(storeTid)
return { tenantId: storeTid, memberId: mid }
} catch (_) {
// cai pro fallback (último membership active)
}
}
// 2) fallback
const { data, error } = await supabase
.from('tenant_members')
.select('id, tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false }) // se existir
.order('created_at', { ascending: false })
.limit(1)
.single()
@@ -904,20 +862,69 @@ async function onSubmit () {
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
// depois do sanitize
const payload = sanitizePayload(form.value, ownerId)
// multi-tenant obrigatório
payload.tenant_id = tenantId
payload.responsible_member_id = memberId
// ✅ validações mínimas (NÃO DEIXA CHEGAR NO BANCO)
const nome = String(form.value?.nome_completo || '').trim()
if (!nome) {
toast.add({
severity: 'warn',
summary: 'Nome obrigatório',
detail: 'Preencha “Nome completo” para salvar o paciente.',
life: 3500
})
// abre o painel certo (você já tem navItems: "Informações pessoais" é o 0)
await openPanel(0)
return
}
// ---------------------------
// EDIT
// ---------------------------
if (isEdit.value) {
await updatePatient(patientId.value, payload)
// ✅ Se houver avatar selecionado, sobe e grava avatar_url
await maybeUploadAvatar(ownerId, patientId.value)
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
} else {
const created = await createPatient(payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
return
}
// ---------------------------
// CREATE
// ---------------------------
const created = await createPatient(payload)
// ✅ upload do avatar usando ID recém-criado
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// ✅ NÃO navega para /cadastro/:id (fica em /admin/pacientes/cadastro)
// Em vez disso, reseta o formulário para novo cadastro:
form.value = resetForm()
grupoIdSelecionado.value = null
tagIdsSelecionadas.value = []
avatarFile.value = null
revokePreview()
avatarPreviewUrl.value = ''
// volta pro primeiro painel (UX boa)
await openPanel(0)
return
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
@@ -925,8 +932,6 @@ async function onSubmit () {
saving.value = false
}
}
// ------------------------------------------------------
// Delete
// ------------------------------------------------------
@@ -968,7 +973,7 @@ async function doDelete () {
}
// ------------------------------------------------------
// Fake fill (opcional)
// Fake fill (opcional) — mantive como você tinha
// ------------------------------------------------------
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
@@ -1036,7 +1041,6 @@ function fillRandomPatient () {
form.value = {
...resetForm(),
nome_completo: nomeCompleto,
telefone: randomPhoneBR(),
email_principal: randomEmailFromName(nomeCompleto),
@@ -1076,16 +1080,10 @@ function fillRandomPatient () {
cobranca_no_responsavel: true,
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
avatar_url: ''
}
// Grupo
if (Array.isArray(groups.value) && groups.value.length) {
grupoIdSelecionado.value = pick(groups.value).id
}
// Tags
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
if (Array.isArray(tags.value) && tags.value.length) {
const shuffled = [...tags.value].sort(() => Math.random() - 0.5)
tagIdsSelecionadas.value = shuffled.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id)
@@ -1118,137 +1116,88 @@ const maritalStatusOptions = [
const createGroupDialog = ref(false)
const createGroupSaving = ref(false)
const createGroupError = ref('')
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default
const newGroup = ref({ name: '', color: '#6366F1' })
const createTagDialog = ref(false)
const createTagSaving = ref(false)
const createTagError = ref('')
const newTag = ref({ name: '', color: '#22C55E' }) // green default
const newTag = ref({ name: '', color: '#22C55E' })
function openGroupDlg(mode = 'create') {
// por enquanto só create
function openGroupDlg () {
createGroupError.value = ''
newGroup.value = { name: '', color: '#6366F1' }
createGroupDialog.value = true
}
function openTagDlg(mode = 'create') {
// por enquanto só create
function openTagDlg () {
createTagError.value = ''
newTag.value = { name: '', color: '#22C55E' }
createTagDialog.value = true
}
// ------------------------------------------------------
// Persist: Grupo
// ------------------------------------------------------
async function createGroupPersist() {
async function createGroupPersist () {
if (createGroupSaving.value) return
createGroupError.value = ''
const name = String(newGroup.value?.name || '').trim()
const color = String(newGroup.value?.color || '').trim() || '#6366F1'
if (!name) {
createGroupError.value = 'Informe um nome para o grupo.'
return
}
if (!name) { createGroupError.value = 'Informe um nome para o grupo.'; return }
createGroupSaving.value = true
try {
const ownerId = await getOwnerId()
// Tenta schema PT-BR primeiro (pelo teu listGroups)
const { tenantId } = await resolveTenantContextOrFail()
let createdId = null
{
const { data, error } = await supabase
.from('patient_groups')
.insert({
owner_id: ownerId,
nome: name,
descricao: null,
cor: color,
is_system: false,
is_active: true
})
.select('id')
.single()
if (!error) createdId = data?.id || null
else {
// fallback (caso seu schema seja EN)
const { data: d2, error: e2 } = await supabase
.from('patient_groups')
.insert({
owner_id: ownerId,
name,
description: null,
color,
is_system: false,
is_active: true
})
.select('id')
.single()
if (e2) throw e2
createdId = d2?.id || null
}
}
const { data, error } = await supabase
.from('patient_groups')
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true })
.select('id')
.single()
if (error) throw error
createdId = data?.id || null
// Recarrega lista e seleciona o novo
groups.value = await listGroups()
if (createdId) grupoIdSelecionado.value = createdId
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 })
createGroupDialog.value = false
} catch (e) {
createGroupError.value = e?.message || 'Falha ao criar grupo.'
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
createGroupError.value = 'Já existe um grupo com esse nome.'
} else {
createGroupError.value = msg || 'Falha ao criar grupo.'
}
} finally {
createGroupSaving.value = false
}
}
// ------------------------------------------------------
// Persist: Tag
// ------------------------------------------------------
async function createTagPersist() {
async function createTagPersist () {
if (createTagSaving.value) return
createTagError.value = ''
const name = String(newTag.value?.name || '').trim()
const color = String(newTag.value?.color || '').trim() || '#22C55E'
if (!name) {
createTagError.value = 'Informe um nome para a tag.'
return
}
if (!name) { createTagError.value = 'Informe um nome para a tag.'; return }
createTagSaving.value = true
try {
const ownerId = await getOwnerId()
// Tenta schema EN primeiro (pelo teu listTags)
const { tenantId } = await resolveTenantContextOrFail()
let createdId = null
{
const { data, error } = await supabase
.from('patient_tags')
.insert({ owner_id: ownerId, name, color })
.select('id')
.single()
if (!error) createdId = data?.id || null
else {
// fallback PT-BR
const { data: d2, error: e2 } = await supabase
.from('patient_tags')
.insert({ owner_id: ownerId, nome: name, cor: color })
.select('id')
.single()
if (e2) throw e2
createdId = d2?.id || null
}
}
const { data, error } = await supabase
.from('patient_tags')
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color })
.select('id')
.single()
if (error) throw error
createdId = data?.id || null
// Recarrega lista e já marca a nova na seleção
tags.value = await listTags()
if (createdId) {
const set = new Set([...(tagIdsSelecionadas.value || []), createdId])
@@ -1258,7 +1207,12 @@ async function createTagPersist() {
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
createTagDialog.value = false
} catch (e) {
createTagError.value = e?.message || 'Falha ao criar tag.'
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
createTagError.value = 'Já existe uma tag com esse nome.'
} else {
createTagError.value = msg || 'Falha ao criar tag.'
}
} finally {
createTagSaving.value = false
}
@@ -1285,11 +1239,12 @@ async function createTagPersist() {
<div class="flex flex-wrap gap-2">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
@click="fillRandomPatient"
@click="fillRandomPatient"
/>
<Button
label="Voltar"
@@ -1931,6 +1886,7 @@ async function createTagPersist() {
<Dialog
v-model:visible="createGroupDialog"
modal
:draggable="false"
header="Criar grupo"
:style="{ width: '26rem' }"
:closable="!createGroupSaving"
@@ -1965,6 +1921,7 @@ async function createTagPersist() {
<Dialog
v-model:visible="createTagDialog"
modal
:draggable="false"
header="Criar tag"
:style="{ width: '26rem' }"
:closable="!createTagSaving"
@@ -1,249 +1,240 @@
<template>
<div class="p-4">
<!-- Top header -->
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
<i class="pi pi-link text-lg"></i>
</div>
<Toast />
<div class="min-w-0">
<div class="text-2xl font-semibold text-slate-900 leading-tight">
Cadastro Externo
</div>
<div class="text-slate-600 mt-1">
Gere um link para o paciente preencher o pré-cadastro com calma e segurança.
</div>
</div>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="extlink-sentinel" />
<!-- Hero sticky -->
<div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
<div class="extlink-hero__blobs" aria-hidden="true">
<div class="extlink-hero__blob extlink-hero__blob--1" />
<div class="extlink-hero__blob extlink-hero__blob--2" />
</div>
<!-- Row 1 -->
<div class="extlink-hero__row1">
<div class="extlink-hero__brand">
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
<div class="min-w-0">
<div class="extlink-hero__title">Link de Cadastro</div>
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border transition-colors"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
/>
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
</span>
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
class="rounded-full"
:loading="rotating"
@click="rotateLink"
/>
</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) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Main grid -->
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4">
<!-- Left: Link card -->
<div class="lg:col-span-7">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<!-- Card head -->
<div class="p-5 border-b border-slate-200">
<div class="flex items-start justify-between gap-3">
<!-- Divider -->
<Divider class="extlink-hero__divider my-2" />
<!-- Row 2: link rápido (oculto no mobile) -->
<div class="extlink-hero__row2">
<div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Gerando link
</div>
<InputGroup v-else class="max-w-2xl">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
</InputGroup>
</div>
</div>
<!-- Conteúdo -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- Esquerda: ações do link -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Card principal: link -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
</div>
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span class="h-2 w-2 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
</span>
</div>
<div class="p-5 space-y-4">
<!-- Skeleton -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
</div>
<div v-else class="space-y-4">
<!-- Link com ações -->
<InputGroup>
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
</InputGroup>
<div class="text-xs text-[var(--text-color-secondary)]">
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
</div>
<!-- CTAs rápidas -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button class="extlink-cta-btn" @click="copyLink">
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
<i class="pi pi-copy" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
</div>
</button>
<button class="extlink-cta-btn" @click="copyInviteMessage">
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
<i class="pi pi-comment" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
<div class="text-xs text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
</div>
</button>
</div>
<!-- Aviso -->
<Message severity="warn" :closable="false">
<b>Dica:</b> ao gerar um novo link, o anterior é revogado. Use isso quando quiser invalidar um link compartilhado.
</Message>
</div>
</div>
</div>
<!-- Mensagem pronta -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
<i class="pi pi-comment text-sm text-[var(--text-color-secondary)]" />
Mensagem pronta para envio
</div>
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
<div class="rounded-xl bg-[var(--surface-ground)] border border-[var(--surface-border)] p-4 text-sm text-[var(--text-color)] leading-relaxed">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono text-xs break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
</div>
<div class="mt-3">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
class="rounded-full"
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
</div>
</div>
</div>
<!-- Direita: instruções -->
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
<!-- Como funciona -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)]">
<div class="font-semibold text-[var(--text-color)]">Como funciona</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="extlink-step shrink-0">1</div>
<div class="min-w-0">
<div class="text-lg font-semibold text-slate-900">Seu link</div>
<div class="text-slate-600 text-sm mt-1">
Envie este link ao paciente. Ele abre a página de cadastro externo.
</div>
<div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
</div>
<div class="hidden md:flex items-center gap-2">
<span
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border"
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
></span>
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
</span>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">2</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">O paciente preenche</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Campos opcionais podem ficar em branco. Menos fricção, mais adesão.</div>
</div>
</div>
</div>
<!-- Card content -->
<div class="p-5">
<!-- Skeleton while loading -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<Message severity="info" :closable="false">
Gerando seu link...
</Message>
</div>
<div v-else class="space-y-4">
<!-- Link display + quick actions -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-slate-700">Link público</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<div class="flex-1 min-w-0">
<InputText
readonly
:value="publicUrl"
class="w-full"
/>
<div class="mt-1 text-xs text-slate-500 break-words">
Token: <span class="font-mono">{{ inviteToken }}</span>
</div>
</div>
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
<Button
class="w-full"
icon="pi pi-copy"
label="Copiar"
severity="secondary"
outlined
@click="copyLink"
/>
<Button
class="w-full"
icon="pi pi-external-link"
label="Abrir"
severity="secondary"
outlined
@click="openLink"
/>
</div>
</div>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">3</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.</div>
</div>
<!-- Big CTA -->
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<div class="font-semibold text-slate-900">Envio rápido</div>
<div class="text-sm text-slate-600 mt-1">
Copie e mande por WhatsApp / e-mail. O paciente preenche e você recebe o cadastro no sistema.
</div>
</div>
<Button
icon="pi pi-copy"
label="Copiar link agora"
class="md:shrink-0"
@click="copyLink"
/>
</div>
</div>
<!-- Safety note -->
<Message severity="warn" :closable="false">
<b>Dica:</b> ao gerar um novo link, o anterior deve deixar de funcionar. Use isso quando você quiser revogar um link que foi compartilhado.
</Message>
</div>
</div>
</li>
</ol>
</div>
</div>
<!-- Right: Concept / Instructions -->
<div class="lg:col-span-5">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="p-5 border-b border-slate-200">
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
<div class="text-slate-600 text-sm mt-1">
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você envia o link</div>
<div class="text-sm text-slate-600 mt-1">
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">O paciente preenche</div>
<div class="text-sm text-slate-600 mt-1">
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você recebe no admin</div>
<div class="text-sm text-slate-600 mt-1">
Os dados entram como cadastro recebido. Você revisa, completa e transforma em paciente quando quiser.
</div>
</div>
</li>
</ol>
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="font-semibold text-slate-900 flex items-center gap-2">
<i class="pi pi-shield text-slate-700"></i>
Boas práticas
</div>
<ul class="mt-2 space-y-2 text-sm text-slate-700">
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Envie junto uma mensagem curta: preencha com calma; campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
</li>
</ul>
</div>
<div class="mt-4 text-xs text-slate-500">
Se você quiser, eu deixo este card ainda mais noir (contraste, microtextos, ícones, sombras) sem perder legibilidade.
</div>
</div>
</div>
<!-- Small helper card -->
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
<div class="text-sm text-slate-600 mt-1">
Se quiser, use este texto ao enviar o link:
</div>
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
</div>
<div class="mt-3 flex gap-2">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
</div>
<!-- Boas práticas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
Boas práticas
</div>
<ul class="space-y-2.5">
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Informe o paciente que campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Evite divulgar em público; é um link para compartilhamento individual.</span>
</li>
</ul>
</div>
</div>
<!-- Toast is global in layout usually; if not, add <Toast /> -->
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import Message from 'primevue/message'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
@@ -252,12 +243,25 @@ const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
/**
* Se o cadastro externo estiver em outro domínio, fixe aqui:
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
* se vazio, usa window.location.origin
*/
const PUBLIC_BASE_URL = '' // opcional
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ────────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
{ separator: true },
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
])
// ── URL base ────────────────────────────────────────────────
const PUBLIC_BASE_URL = ''
const origin = computed(() => {
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
@@ -269,12 +273,13 @@ const publicUrl = computed(() => {
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
function newToken () {
// ── Token helpers ───────────────────────────────────────────
function newToken() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function requireUserId () {
async function requireUserId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
@@ -282,7 +287,7 @@ async function requireUserId () {
return uid
}
async function loadOrCreateInvite () {
async function loadOrCreateInvite() {
const uid = await requireUserId()
const { data, error } = await supabase
@@ -310,16 +315,14 @@ async function loadOrCreateInvite () {
inviteToken.value = t
}
async function rotateLink () {
async function rotateLink() {
rotating.value = true
try {
const uid = await requireUserId()
const t = newToken()
// tenta RPC primeiro
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (rpc.error) {
// fallback: desativa todos os ativos e cria um novo
const { error: e1 } = await supabase
.from('patient_invites')
.update({ active: false, updated_at: new Date().toISOString() })
@@ -334,7 +337,7 @@ async function rotateLink () {
}
inviteToken.value = t
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado. O anterior foi revogado.', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
} finally {
@@ -342,40 +345,138 @@ async function rotateLink () {
}
}
async function copyLink () {
async function copyLink() {
try {
if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 })
} catch {
// fallback clássico
window.prompt('Copie o link:', publicUrl.value)
}
}
function openLink () {
function openLink() {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
async function copyInviteMessage () {
async function copyInviteMessage() {
try {
if (!publicUrl.value) return
const msg =
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
${publicUrl.value}`
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
await navigator.clipboard.writeText(msg)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
onMounted(async () => {
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)
try {
await loadOrCreateInvite()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
}
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<style scoped>
/* ── Sentinel ─────────────────────────────────────── */
.extlink-sentinel { height: 1px; }
/* ── Hero ─────────────────────────────────────────── */
.extlink-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;
}
.extlink-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
/* Blobs decorativos */
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
/* Linha 1 */
.extlink-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.extlink-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.extlink-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);
}
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 */
.extlink-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.extlink-hero__divider,
.extlink-hero__row2 { display: none; }
}
/* ── CTA button ───────────────────────────────────── */
.extlink-cta-btn {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
border-radius: 1rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
text-align: left;
}
.extlink-cta-btn:hover {
background: var(--surface-hover);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.extlink-cta-btn:active { transform: translateY(0); }
.extlink-cta-btn__icon {
display: grid; place-items: center;
width: 2.25rem; height: 2.25rem;
border-radius: 0.75rem; flex-shrink: 0;
font-size: 1rem;
}
/* ── Step numbers ─────────────────────────────────── */
.extlink-step {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 0.625rem;
font-size: 0.8rem; font-weight: 700;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
</style>
@@ -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>