Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
|
||||
<Button
|
||||
label="Gerar novo link"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="rotating"
|
||||
@click="rotateLink"
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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 já foi compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</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 Message from 'primevue/message'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
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
|
||||
|
||||
const origin = computed(() => {
|
||||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
|
||||
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||
})
|
||||
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return ''
|
||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
|
||||
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 () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado')
|
||||
return uid
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite () {
|
||||
const uid = await requireUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_invites')
|
||||
.select('token, active')
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const token = data?.[0]?.token
|
||||
if (token) {
|
||||
inviteToken.value = token
|
||||
return
|
||||
}
|
||||
|
||||
const t = newToken()
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
|
||||
if (insErr) throw insErr
|
||||
inviteToken.value = t
|
||||
}
|
||||
|
||||
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() })
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
if (e1) throw e1
|
||||
|
||||
const { error: e2 } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
if (e2) throw e2
|
||||
}
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
|
||||
} finally {
|
||||
rotating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
} catch {
|
||||
// fallback clássico
|
||||
window.prompt('Copie o link:', publicUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openLink () {
|
||||
if (!publicUrl.value) return
|
||||
window.open(publicUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
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}`
|
||||
await navigator.clipboard.writeText(msg)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user