Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
@@ -14,74 +14,84 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Transition name="fade-up" appear>
|
||||
<div class="flex flex-col items-center justify-center gap-6" :class="containerClass">
|
||||
|
||||
<!-- Motivação -->
|
||||
<div class="flex flex-col items-center gap-2 text-center px-6">
|
||||
<span class="text-base font-semibold text-[var(--text-color,#1e293b)] leading-snug max-w-[320px]">
|
||||
{{ motivation || '...' }}
|
||||
</span>
|
||||
<span class="text-sm text-[var(--text-color-secondary,#64748b)]">
|
||||
{{ action }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-[220px] h-[3px] rounded-full bg-[var(--surface-border,#e2e8f0)] overflow-hidden">
|
||||
<div class="progress-bar h-full rounded-full bg-[var(--primary-color,#6366f1)]" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
defineProps({
|
||||
action: { type: String, default: 'Carregando...' },
|
||||
containerClass: { type: String, default: 'py-24' },
|
||||
})
|
||||
action: { type: String, default: 'Carregando...' },
|
||||
containerClass: { type: String, default: 'py-24' }
|
||||
});
|
||||
|
||||
const motivation = ref(null)
|
||||
const motivation = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/loading-phrases.json')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
const list = json.motivations || []
|
||||
motivation.value = list.length
|
||||
? list[Math.floor(Math.random() * list.length)]
|
||||
: 'Carregando...'
|
||||
} catch (e) {
|
||||
console.warn('[AppLoadingPhrases] fetch falhou:', e)
|
||||
motivation.value = 'Carregando...'
|
||||
}
|
||||
})
|
||||
try {
|
||||
const res = await fetch('/loading-phrases.json');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
const list = json.motivations || [];
|
||||
motivation.value = list.length ? list[Math.floor(Math.random() * list.length)] : 'Carregando...';
|
||||
} catch (e) {
|
||||
console.warn('[AppLoadingPhrases] fetch falhou:', e);
|
||||
motivation.value = 'Carregando...';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade-up" appear>
|
||||
<div class="flex flex-col items-center justify-center gap-6" :class="containerClass">
|
||||
<!-- Motivação -->
|
||||
<div class="flex flex-col items-center gap-2 text-center px-6">
|
||||
<span class="text-base font-semibold text-[var(--text-color,#1e293b)] leading-snug max-w-[320px]">
|
||||
{{ motivation || '...' }}
|
||||
</span>
|
||||
<span class="text-sm text-[var(--text-color-secondary,#64748b)]">
|
||||
{{ action }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-[220px] h-[3px] rounded-full bg-[var(--surface-border,#e2e8f0)] overflow-hidden">
|
||||
<div class="progress-bar h-full rounded-full bg-[var(--primary-color,#6366f1)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Entrada do componente inteiro */
|
||||
.fade-up-enter-active {
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
transition:
|
||||
opacity 0.45s ease,
|
||||
transform 0.45s ease;
|
||||
}
|
||||
.fade-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
/* Progress bar — vai de 0% a 85% em ~2.5s, para não "completar" antes do loading acabar */
|
||||
.progress-bar {
|
||||
animation: progress-indeterminate 1.6s ease-in-out infinite;
|
||||
transform-origin: left;
|
||||
animation: progress-indeterminate 1.6s ease-in-out infinite;
|
||||
transform-origin: left;
|
||||
}
|
||||
@keyframes progress-indeterminate {
|
||||
0% { margin-left: 0%; width: 0%; }
|
||||
30% { margin-left: 0%; width: 60%; }
|
||||
70% { margin-left: 40%; width: 60%; }
|
||||
100% { margin-left: 100%; width: 0%; }
|
||||
0% {
|
||||
margin-left: 0%;
|
||||
width: 0%;
|
||||
}
|
||||
30% {
|
||||
margin-left: 0%;
|
||||
width: 60%;
|
||||
}
|
||||
70% {
|
||||
margin-left: 40%;
|
||||
width: 60%;
|
||||
}
|
||||
100% {
|
||||
margin-left: 100%;
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -14,31 +14,31 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Transition name="loaded-phrase-in" appear>
|
||||
<div v-if="phrase" class="loaded-phrase-block">
|
||||
<div class="loaded-phrase-block__header">
|
||||
<i class="pi pi-check-circle loaded-phrase-block__icon" />
|
||||
<span class="loaded-phrase-block__title">Ambiente carregado!</span>
|
||||
</div>
|
||||
<p class="loaded-phrase-block__text">{{ phrase }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const phrase = ref(null)
|
||||
const phrase = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/loading-phrases.json')
|
||||
const json = await res.json()
|
||||
const list = json.motivations || []
|
||||
phrase.value = list.length ? list[Math.floor(Math.random() * list.length)] : null
|
||||
} catch {
|
||||
phrase.value = null
|
||||
}
|
||||
})
|
||||
try {
|
||||
const res = await fetch('/loading-phrases.json');
|
||||
const json = await res.json();
|
||||
const list = json.motivations || [];
|
||||
phrase.value = list.length ? list[Math.floor(Math.random() * list.length)] : null;
|
||||
} catch {
|
||||
phrase.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="loaded-phrase-in" appear>
|
||||
<div v-if="phrase" class="loaded-phrase-block">
|
||||
<div class="loaded-phrase-block__header">
|
||||
<i class="pi pi-check-circle loaded-phrase-block__icon" />
|
||||
<span class="loaded-phrase-block__title">Ambiente carregado!</span>
|
||||
</div>
|
||||
<p class="loaded-phrase-block__text">{{ phrase }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -26,172 +26,146 @@
|
||||
| created — paciente criado ou atualizado com sucesso
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="isOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="false"
|
||||
:dismissableMask="false"
|
||||
:maximizable="false"
|
||||
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
|
||||
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<!-- ── Header ─────────────────────────────────────── -->
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<!-- Título -->
|
||||
<span class="text-base font-semibold text-[var(--text-color)] leading-tight">
|
||||
{{ patientId ? 'Editar Paciente' : 'Cadastro de Paciente' }}
|
||||
</span>
|
||||
|
||||
<!-- Botões à direita -->
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
|
||||
<!-- Preencher tudo (só testMODE) -->
|
||||
<Button
|
||||
v-if="pageRef?.canSee?.('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
|
||||
@click="pageRef?.fillRandomPatient?.()"
|
||||
/>
|
||||
|
||||
<!-- Excluir (só em edição) -->
|
||||
<Button
|
||||
v-if="patientId"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:loading="pageRef?.deleting?.value"
|
||||
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
|
||||
title="Excluir paciente"
|
||||
@click="pageRef?.confirmDelete?.()"
|
||||
/>
|
||||
|
||||
<!-- Maximizar -->
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
:title="maximized ? 'Restaurar' : 'Maximizar'"
|
||||
@click="maximized = !maximized"
|
||||
>
|
||||
<i :class="maximized ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" />
|
||||
</button>
|
||||
|
||||
<!-- Fechar -->
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
title="Fechar"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Conteúdo ────────────────────────────────────── -->
|
||||
<PatientsCadastroPage
|
||||
ref="pageRef"
|
||||
:dialog-mode="true"
|
||||
:patient-id="patientId"
|
||||
@cancel="isOpen = false"
|
||||
@created="onCreated"
|
||||
/>
|
||||
|
||||
<!-- ── Footer ─────────────────────────────────────── -->
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
text
|
||||
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<!-- Na rota de pacientes: só "Salvar" -->
|
||||
<Button
|
||||
v-if="isOnPatientsPage"
|
||||
label="Salvar"
|
||||
:loading="!!pageRef?.saving?.value"
|
||||
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
|
||||
@click="submitWith('only')"
|
||||
/>
|
||||
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<template v-else>
|
||||
<Button
|
||||
label="Salvar e fechar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="pendingMode === 'only' && !!pageRef?.saving?.value"
|
||||
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
|
||||
@click="submitWith('only')"
|
||||
/>
|
||||
<Button
|
||||
label="Salvar e ver pacientes"
|
||||
:loading="pendingMode === 'view' && !!pageRef?.saving?.value"
|
||||
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
|
||||
@click="submitWith('view')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PatientsCadastroPage from '@/features/patients/cadastro/PatientsCadastroPage.vue'
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import PatientsCadastroPage from '@/features/patients/cadastro/PatientsCadastroPage.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'created'])
|
||||
modelValue: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null }
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'created']);
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
});
|
||||
|
||||
// Reset maximized when dialog opens
|
||||
watch(() => props.modelValue, (v) => { if (!v) maximized.value = false })
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (!v) maximized.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const maximized = ref(false)
|
||||
const pageRef = ref(null)
|
||||
const pendingMode = ref('only')
|
||||
const maximized = ref(false);
|
||||
const pageRef = ref(null);
|
||||
const pendingMode = ref('only');
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isOnPatientsPage = computed(() => {
|
||||
const p = String(route.path || '')
|
||||
return p.includes('/patients') || p.includes('/pacientes')
|
||||
})
|
||||
const p = String(route.path || '');
|
||||
return p.includes('/patients') || p.includes('/pacientes');
|
||||
});
|
||||
|
||||
function patientsListRoute () {
|
||||
const p = String(route.path || '')
|
||||
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes'
|
||||
function patientsListRoute() {
|
||||
const p = String(route.path || '');
|
||||
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
|
||||
}
|
||||
|
||||
function submitWith (mode) {
|
||||
pendingMode.value = mode
|
||||
pageRef.value?.onSubmit()
|
||||
function submitWith(mode) {
|
||||
pendingMode.value = mode;
|
||||
pageRef.value?.onSubmit();
|
||||
}
|
||||
|
||||
async function onCreated (data) {
|
||||
isOpen.value = false
|
||||
emit('created', data)
|
||||
if (pendingMode.value === 'view') {
|
||||
await router.push(patientsListRoute())
|
||||
}
|
||||
async function onCreated(data) {
|
||||
isOpen.value = false;
|
||||
emit('created', data);
|
||||
if (pendingMode.value === 'view') {
|
||||
await router.push(patientsListRoute());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="isOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="false"
|
||||
:dismissableMask="false"
|
||||
:maximizable="false"
|
||||
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
|
||||
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<!-- ── Header ─────────────────────────────────────── -->
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<!-- Título -->
|
||||
<span class="text-base font-semibold text-[var(--text-color)] leading-tight">
|
||||
{{ patientId ? 'Editar Paciente' : 'Cadastro de Paciente' }}
|
||||
</span>
|
||||
|
||||
<!-- Botões à direita -->
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<!-- Preencher tudo (só testMODE) -->
|
||||
<Button
|
||||
v-if="pageRef?.canSee?.('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
|
||||
@click="pageRef?.fillRandomPatient?.()"
|
||||
/>
|
||||
|
||||
<!-- Excluir (só em edição) -->
|
||||
<Button
|
||||
v-if="patientId"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:loading="pageRef?.deleting?.value"
|
||||
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
|
||||
title="Excluir paciente"
|
||||
@click="pageRef?.confirmDelete?.()"
|
||||
/>
|
||||
|
||||
<!-- Maximizar -->
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
:title="maximized ? 'Restaurar' : 'Maximizar'"
|
||||
@click="maximized = !maximized"
|
||||
>
|
||||
<i :class="maximized ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" />
|
||||
</button>
|
||||
|
||||
<!-- Fechar -->
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
|
||||
title="Fechar"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Conteúdo ────────────────────────────────────── -->
|
||||
<PatientsCadastroPage ref="pageRef" :dialog-mode="true" :patient-id="patientId" @cancel="isOpen = false" @created="onCreated" />
|
||||
|
||||
<!-- ── Footer ─────────────────────────────────────── -->
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Cancelar" severity="secondary" text :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="isOpen = false" />
|
||||
<!-- Na rota de pacientes: só "Salvar" -->
|
||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<template v-else>
|
||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -26,183 +26,154 @@
|
||||
| toggle(event) — abre/fecha o Popover
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<PatientCadastroDialog v-model="showCadastroDialog" />
|
||||
|
||||
<Popover ref="popRef">
|
||||
<div class="flex flex-col min-w-[230px]">
|
||||
|
||||
<!-- Cadastro rápido -->
|
||||
<button
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
|
||||
@click="onQuickCreate"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-bolt text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cadastro completo -->
|
||||
<button
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
|
||||
@click="onGoComplete"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-user-plus text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Divisor -->
|
||||
<div class="mx-3 my-1.5 border-t border-[var(--surface-border,#e2e8f0)]" />
|
||||
|
||||
<!-- Link de cadastro -->
|
||||
<div class="px-3 pb-3">
|
||||
<div class="flex items-center gap-1.5 text-[0.68rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 mb-2">
|
||||
<i class="pi pi-link text-[0.6rem]" />
|
||||
Link de cadastro
|
||||
</div>
|
||||
|
||||
<!-- Carregando token -->
|
||||
<div v-if="loadingToken" class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] py-1">
|
||||
<i class="pi pi-spin pi-spinner text-[0.7rem]" /> Carregando link…
|
||||
</div>
|
||||
|
||||
<!-- Sem token ainda -->
|
||||
<div v-else-if="!inviteToken" class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-60 py-1">
|
||||
Nenhum link ativo.
|
||||
<button class="underline cursor-pointer border-0 bg-transparent text-[var(--primary-color,#6366f1)]" @click="loadToken">Tentar novamente</button>
|
||||
</div>
|
||||
|
||||
<!-- URL + ações -->
|
||||
<template v-else>
|
||||
<InputGroup class="w-full">
|
||||
<InputText
|
||||
:value="publicUrl"
|
||||
readonly
|
||||
class="text-[0.68rem] font-mono"
|
||||
style="min-width: 0"
|
||||
/>
|
||||
<InputGroupAddon
|
||||
class="cursor-pointer hover:bg-[var(--surface-hover)] transition-colors"
|
||||
title="Copiar link"
|
||||
@click="copyLink"
|
||||
>
|
||||
<i class="pi pi-copy text-sm" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<div class="flex gap-1 mt-2">
|
||||
<Button
|
||||
label="Copiar mensagem"
|
||||
icon="pi pi-comment"
|
||||
text
|
||||
size="small"
|
||||
class="flex-1 text-xs rounded-full"
|
||||
@click="copyMessage"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
v-tooltip.top="'Abrir no navegador'"
|
||||
@click="openLink"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Popover from 'primevue/popover'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import PatientCadastroDialog from './PatientCadastroDialog.vue'
|
||||
import { ref, computed } from 'vue';
|
||||
import Popover from 'primevue/popover';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import PatientCadastroDialog from './PatientCadastroDialog.vue';
|
||||
|
||||
const emit = defineEmits(['quick-create'])
|
||||
const showCadastroDialog = ref(false)
|
||||
const toast = useToast()
|
||||
const emit = defineEmits(['quick-create']);
|
||||
const showCadastroDialog = ref(false);
|
||||
const toast = useToast();
|
||||
|
||||
const popRef = ref(null)
|
||||
const inviteToken = ref('')
|
||||
const loadingToken = ref(false)
|
||||
let tokenLoaded = false
|
||||
const popRef = ref(null);
|
||||
const inviteToken = ref('');
|
||||
const loadingToken = ref(false);
|
||||
let tokenLoaded = false;
|
||||
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return ''
|
||||
return `${window.location.origin}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
if (!inviteToken.value) return '';
|
||||
return `${window.location.origin}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`;
|
||||
});
|
||||
|
||||
async function loadToken () {
|
||||
if (tokenLoaded || loadingToken.value) return
|
||||
loadingToken.value = true
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser()
|
||||
const uid = authData?.user?.id
|
||||
if (!uid) return
|
||||
const { data } = await supabase
|
||||
.from('patient_invites')
|
||||
.select('token')
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
if (data?.[0]?.token) {
|
||||
inviteToken.value = data[0].token
|
||||
tokenLoaded = true
|
||||
async function loadToken() {
|
||||
if (tokenLoaded || loadingToken.value) return;
|
||||
loadingToken.value = true;
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const uid = authData?.user?.id;
|
||||
if (!uid) return;
|
||||
const { data } = await supabase.from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
|
||||
if (data?.[0]?.token) {
|
||||
inviteToken.value = data[0].token;
|
||||
tokenLoaded = true;
|
||||
}
|
||||
} catch {
|
||||
/* silencioso */
|
||||
} finally {
|
||||
loadingToken.value = false;
|
||||
}
|
||||
} catch { /* silencioso */ } finally {
|
||||
loadingToken.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggle (event) {
|
||||
popRef.value?.toggle(event)
|
||||
loadToken()
|
||||
function toggle(event) {
|
||||
popRef.value?.toggle(event);
|
||||
loadToken();
|
||||
}
|
||||
|
||||
function close () {
|
||||
try { popRef.value?.hide() } catch {}
|
||||
function close() {
|
||||
try {
|
||||
popRef.value?.hide();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onQuickCreate () { close(); emit('quick-create') }
|
||||
function onGoComplete () { close(); showCadastroDialog.value = true }
|
||||
|
||||
async function copyLink () {
|
||||
if (!publicUrl.value) return
|
||||
try {
|
||||
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 onQuickCreate() {
|
||||
close();
|
||||
emit('quick-create');
|
||||
}
|
||||
function onGoComplete() {
|
||||
close();
|
||||
showCadastroDialog.value = true;
|
||||
}
|
||||
|
||||
async function copyMessage () {
|
||||
if (!publicUrl.value) return
|
||||
try {
|
||||
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 {}
|
||||
async function copyLink() {
|
||||
if (!publicUrl.value) return;
|
||||
try {
|
||||
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 copyMessage() {
|
||||
if (!publicUrl.value) return;
|
||||
try {
|
||||
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 {}
|
||||
}
|
||||
|
||||
defineExpose({ toggle, close })
|
||||
function openLink() {
|
||||
if (!publicUrl.value) return;
|
||||
window.open(publicUrl.value, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
defineExpose({ toggle, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PatientCadastroDialog v-model="showCadastroDialog" />
|
||||
|
||||
<Popover ref="popRef">
|
||||
<div class="flex flex-col min-w-[230px]">
|
||||
<!-- Cadastro rápido -->
|
||||
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onQuickCreate">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-bolt text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cadastro completo -->
|
||||
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onGoComplete">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-user-plus text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Divisor -->
|
||||
<div class="mx-3 my-1.5 border-t border-[var(--surface-border,#e2e8f0)]" />
|
||||
|
||||
<!-- Link de cadastro -->
|
||||
<div class="px-3 pb-3">
|
||||
<div class="flex items-center gap-1.5 text-[0.68rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 mb-2">
|
||||
<i class="pi pi-link text-[0.6rem]" />
|
||||
Link de cadastro
|
||||
</div>
|
||||
|
||||
<!-- Carregando token -->
|
||||
<div v-if="loadingToken" class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] py-1"><i class="pi pi-spin pi-spinner text-[0.7rem]" /> Carregando link…</div>
|
||||
|
||||
<!-- Sem token ainda -->
|
||||
<div v-else-if="!inviteToken" class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-60 py-1">
|
||||
Nenhum link ativo.
|
||||
<button class="underline cursor-pointer border-0 bg-transparent text-[var(--primary-color,#6366f1)]" @click="loadToken">Tentar novamente</button>
|
||||
</div>
|
||||
|
||||
<!-- URL + ações -->
|
||||
<template v-else>
|
||||
<InputGroup class="w-full">
|
||||
<InputText :value="publicUrl" readonly class="text-[0.68rem] font-mono" style="min-width: 0" />
|
||||
<InputGroupAddon class="cursor-pointer hover:bg-[var(--surface-hover)] transition-colors" title="Copiar link" @click="copyLink">
|
||||
<i class="pi pi-copy text-sm" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<div class="flex gap-1 mt-2">
|
||||
<Button label="Copiar mensagem" icon="pi pi-comment" text size="small" class="flex-1 text-xs rounded-full" @click="copyMessage" />
|
||||
<Button icon="pi pi-external-link" text size="small" class="rounded-full" v-tooltip.top="'Abrir no navegador'" @click="openLink" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user