diff --git a/src/features/patients/PatientsListPage.vue b/src/features/patients/PatientsListPage.vue
index 2a8ff52..297d065 100644
--- a/src/features/patients/PatientsListPage.vue
+++ b/src/features/patients/PatientsListPage.vue
@@ -34,6 +34,7 @@ import { useRoute, useRouter } from 'vue-router';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue';
+import { usePatientLifecycle } from '@/composables/usePatientLifecycle';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
@@ -104,6 +105,36 @@ const conversationDrawerStore = useConversationDrawerStore();
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
+const { reactivatePatient } = usePatientLifecycle();
+
+// Restaurar paciente arquivado — atalho visível direto na linha (em vez
+// de exigir abrir o menu de "..."). Compartilha reactivatePatient com
+// PatientActionMenu pra manter fonte única de verdade.
+function restaurarPaciente(p) {
+ if (!p?.id) return;
+ confirm.require({
+ message: `Restaurar "${p.nome_completo}" pra lista de pacientes ativos?`,
+ header: 'Restaurar paciente',
+ icon: 'pi pi-undo',
+ acceptLabel: 'Restaurar',
+ rejectLabel: 'Cancelar',
+ accept: async () => {
+ try {
+ const result = await reactivatePatient(p.id);
+ if (!result?.ok) throw result?.error || new Error('Falha ao restaurar.');
+ toast.add({ severity: 'success', summary: 'Restaurado', detail: p.nome_completo, life: 2200 });
+ await fetchAll();
+ } catch (e) {
+ toast.add({
+ severity: 'error',
+ summary: 'Falha ao restaurar',
+ detail: e?.message || 'Tente novamente.',
+ life: 4000
+ });
+ }
+ }
+ });
+}
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null);
@@ -1079,6 +1110,7 @@ function isRecent(row) {
+
@@ -1146,6 +1178,7 @@ function isRecent(row) {
+
diff --git a/src/features/patients/services/patientsRepository.js b/src/features/patients/services/patientsRepository.js
index 7749d02..62f73e8 100644
--- a/src/features/patients/services/patientsRepository.js
+++ b/src/features/patients/services/patientsRepository.js
@@ -95,21 +95,9 @@ export async function softDeletePatient(id, { tenantId } = {}) {
if (error) throw error;
}
-/**
- * Restaura um paciente arquivado — volta status pra 'Ativo'.
- * Inverso explícito do softDeletePatient. Uso: botão "Restaurar"
- * que aparece nas ações quando p.status === 'Arquivado'.
- */
-export async function restorePatient(id, { tenantId } = {}) {
- if (!id) throw new Error('id obrigatório');
- assertTenantId(tenantId);
- const { error } = await supabase
- .from('patients')
- .update({ status: 'Ativo' })
- .eq('id', id)
- .eq('tenant_id', tenantId);
- if (error) throw error;
-}
+// Pra restaurar um paciente arquivado, use `reactivatePatient` do
+// composable `usePatientLifecycle` — fonte única de verdade pra toda
+// transição de status (Inativo/Arquivado/Alta/Encaminhado → Ativo).
// ─────────────────────────────────────────────────────────────────────────
// Groups
diff --git a/src/layout/melissa/MelissaEmbed.vue b/src/layout/melissa/MelissaEmbed.vue
index a9f1f80..7ecb0f9 100644
--- a/src/layout/melissa/MelissaEmbed.vue
+++ b/src/layout/melissa/MelissaEmbed.vue
@@ -74,12 +74,9 @@ const EMBED_MAP = {
icon: 'pi pi-bell',
comp: defineAsyncComponent(() => import('@/views/pages/therapist/NotificationsHistoryPage.vue'))
},
- 'link-externo': {
- label: 'Link externo de cadastro',
- desc: 'Link público pra pacientes preencherem o cadastro online.',
- icon: 'pi pi-share-alt',
- comp: defineAsyncComponent(() => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue'))
- }
+ // 'link-externo' foi promovido pra página nativa MelissaLinkExterno
+ // (eliminado o triplo header). Wire-up agora no MelissaLayout.vue,
+ // não passa mais por aqui.
};
const info = computed(() => EMBED_MAP[props.secaoRota] || null);
diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue
index d2c753c..efbc636 100644
--- a/src/layout/melissa/MelissaLayout.vue
+++ b/src/layout/melissa/MelissaLayout.vue
@@ -36,6 +36,7 @@ import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
import MelissaEmbed from './MelissaEmbed.vue';
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
+import MelissaLinkExterno from './MelissaLinkExterno.vue';
import MelissaMedicos from './MelissaMedicos.vue';
import MelissaEventoPanel from './MelissaEventoPanel.vue';
import { TOQUES, playToque } from './melissaToques';
@@ -173,8 +174,10 @@ const SECOES = {
'link-externo': { label: 'Link externo de cadastro', icon: 'pi pi-share-alt', descricao: 'Link público pra pacientes se cadastrarem.' }
};
-// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna)
-const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
+// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna).
+// 'link-externo' foi promovido pra página nativa (MelissaLinkExterno) pra
+// remover o triplo-header que aparecia no embed.
+const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes'];
// Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes,
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
@@ -182,6 +185,7 @@ const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos'
const MELISSA_NON_CONFIG_SLUGS = new Set([
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
+ 'link-externo',
...MELISSA_EMBED_KEYS
]);
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
@@ -2161,6 +2165,11 @@ function onKeydown(e) {
@close="fecharSecao"
/>
+
+
+/*
+ * MelissaLinkExterno — Página nativa Melissa pra "Link externo de cadastro"
+ * (substitui o embed via MelissaEmbed que duplicava headers).
+ *
+ * Layout 2-col:
+ * - COL 1 — Esquerda (flex 1): card "Seu link público" + card
+ * "Mensagem pronta"
+ * - COL 2 — Direita (~280px): card "Como funciona" + card "Boas práticas"
+ *
+ * Lógica idêntica à PatientsExternalLinkPage (RPC issue_patient_invite +
+ * rotate_patient_invite_token_v2 + copy/openLink). Só o chrome muda pra
+ * casar com o blueprint Melissa (1 header só, sem hero sticky redundante).
+ */
+import { ref, computed, onMounted } from 'vue';
+import { useToast } from 'primevue/usetoast';
+import { supabase } from '@/lib/supabase/client';
+// Button/InputText/InputGroup/InputGroupAddon/Message: auto via PrimeVueResolver
+
+const emit = defineEmits(['close']);
+const toast = useToast();
+
+// ── Estado ─────────────────────────────────────────────────
+const inviteToken = ref('');
+const rotating = ref(false);
+const loading = ref(false);
+
+// ── Conteúdo estático ─────────────────────────────────────
+const howItWorks = [
+ { n: 1, title: 'Você envia o link', desc: 'Por WhatsApp, e-mail ou mensagem direta.' },
+ { n: 2, title: 'O paciente preenche', desc: 'Campos opcionais podem ficar em branco. Menos fricção, mais adesão.' },
+ { n: 3, title: 'Você recebe e converte', desc: 'O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.' }
+];
+
+const goodPractices = [
+ 'Gere um novo link se suspeitar que ele foi repassado indevidamente.',
+ 'Informe o paciente que campos opcionais podem ficar em branco.',
+ 'Evite divulgar em público; é um link para compartilhamento individual.'
+];
+
+// ── URL base ──────────────────────────────────────────────
+const origin = computed(() => {
+ return typeof window !== 'undefined' ? window.location.origin : '';
+});
+
+const publicUrl = computed(() => {
+ if (!inviteToken.value) return '';
+ return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`;
+});
+
+// ── RPC: issue + rotate ───────────────────────────────────
+// Tokens são gerados no SERVIDOR via gen_random_uuid() — cliente nunca
+// gera (segurança: elimina fallback Math.random).
+async function loadOrCreateInvite() {
+ loading.value = true;
+ try {
+ const { data, error } = await supabase.rpc('issue_patient_invite');
+ if (error) throw error;
+ inviteToken.value = data;
+ } catch (err) {
+ toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
+ } finally {
+ loading.value = false;
+ }
+}
+
+async function rotateLink() {
+ rotating.value = true;
+ try {
+ const { data, error } = await supabase.rpc('rotate_patient_invite_token_v2');
+ if (error) throw error;
+ inviteToken.value = data;
+ 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 {
+ rotating.value = false;
+ }
+}
+
+// ── Copy/open helpers ─────────────────────────────────────
+async function copyLink() {
+ try {
+ if (!publicUrl.value) return;
+ await navigator.clipboard.writeText(publicUrl.value);
+ toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 });
+ } catch {
+ 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:\n${publicUrl.value}`;
+ await navigator.clipboard.writeText(msg);
+ 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 });
+ }
+}
+
+// ── Lifecycle ──────────────────────────────────────────────
+onMounted(async () => {
+ await loadOrCreateInvite();
+});
+
+
+
+
+
+
+
+
+ Link externo de cadastro
+
+
+ {{ inviteToken ? 'Link ativo' : 'Gerando…' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Link público pra pacientes preencherem o pré-cadastro online.
+ Compartilhe por WhatsApp/email; o cadastro chega em
+ Cadastros Recebidos pra você converter em paciente.
+
+
+
+
+
+
+
+
+
+
+
+ Seu link público
+ Envie ao paciente por WhatsApp, e-mail ou mensagem direta
+