Copyright, Financeiro, Lançamentos, aprimoramentos de ui

This commit is contained in:
Leonardo
2026-03-21 08:05:40 -03:00
parent 29ed349cf2
commit a89d1f5560
268 changed files with 58870 additions and 1752 deletions
+16 -2
View File
@@ -1,5 +1,19 @@
<!-- src/components/AjudaDrawer.vue -->
<!-- Painel de ajuda lateral home com sessão/docs/faq + navegação interna + votação -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/AjudaDrawer.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+16 -2
View File
@@ -1,5 +1,19 @@
<!-- src/components/AppOfflineOverlay.vue -->
<!-- Detecta offline via eventos nativos do browser + polling de fetch -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/AppOfflineOverlay.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
+55 -6
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ComponentCadastroRapido.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Dialog
v-model:visible="isOpen"
@@ -15,7 +31,7 @@
<div class="min-w-0">
<div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500">
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
Crie um paciente rapidamente.
</div>
</div>
@@ -51,7 +67,7 @@
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="submit"
@keydown.enter.prevent="submit('only')"
/>
</IconField>
<label for="cr-nome">Nome completo *</label>
@@ -71,7 +87,7 @@
:disabled="saving"
inputmode="email"
autocomplete="off"
@keydown.enter.prevent="submit"
@keydown.enter.prevent="submit('only')"
/>
</IconField>
<label for="cr-email">E-mail *</label>
@@ -90,7 +106,7 @@
class="w-full"
variant="filled"
:disabled="saving"
@keydown.enter.prevent="submit"
@keydown.enter.prevent="submit('only')"
/>
</IconField>
<label for="cr-telefone">Telefone *</label>
@@ -111,12 +127,31 @@
:disabled="saving"
@click="close"
/>
<!-- Na rota de pacientes: "Salvar" -->
<Button
v-if="isOnPatientsPage"
label="Salvar"
:loading="saving"
:disabled="saving"
@click="submit"
@click="submit('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="saving"
:disabled="saving"
@click="submit('only')"
/>
<Button
label="Salvar e ver pacientes"
:loading="saving"
:disabled="saving"
@click="submit('view')"
/>
</template>
</div>
</template>
</Dialog>
@@ -124,6 +159,7 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast'
@@ -134,6 +170,13 @@ import Message from 'primevue/message'
import { supabase } from '@/lib/supabase/client'
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const isOnPatientsPage = computed(() => {
const p = String(route.path || '')
return p.includes('/patients') || p.includes('/pacientes')
})
/**
* Lista "curada" de pensadores influentes na psicanálise e seu entorno.
@@ -325,7 +368,12 @@ function generateUser () {
})
}
async function submit () {
function patientsListRoute () {
const p = String(route.path || '')
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes'
}
async function submit (mode = 'only') {
touched.value = true
errorMsg.value = ''
@@ -378,6 +426,7 @@ async function submit () {
emit('created', data)
if (props.closeOnCreated) close()
if (mode === 'view') await router.push(patientsListRoute())
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'
errorMsg.value = msg
@@ -0,0 +1,416 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaEventoFinanceiroPanel.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<!--
AgendaEventoFinanceiroPanel
Painel compacto de status financeiro exibido dentro do modal de sessão.
Mostra o financial_record vinculado ao evento e permite registrar pagamento
ou gerar cobrança sem sair do contexto da agenda.
Props:
evento linha de agenda_eventos (deve ter: id, tipo, billed,
billing_contract_id, price, patient_id, inicio_em)
Emits:
cobranca-atualizada após qualquer mutação, para o pai recarregar
-->
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'
// ── props / emits ─────────────────────────────────────────────────────────────
const props = defineProps({
evento: {
type: Object,
required: true,
},
})
const emit = defineEmits(['cobranca-atualizada'])
// ── external ──────────────────────────────────────────────────────────────────
const toast = useToast()
const confirm = useConfirm()
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro()
// ── estado local ──────────────────────────────────────────────────────────────
const record = ref(null) // financial_record vinculado
const fetching = ref(false)
const generating = ref(false)
// ── opções de método de pagamento ─────────────────────────────────────────────
const PAYMENT_METHODS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' },
]
function paymentLabel (method) {
return PAYMENT_METHODS.find(o => o.value === method)?.label ?? method ?? '—'
}
// ── formatação ─────────────────────────────────────────────────────────────────
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL (v) { return _brl.format(v ?? 0) }
function fmtDate (iso) {
if (!iso) return '—'
const d = iso.includes('T') ? new Date(iso) : new Date(iso + 'T00:00:00')
return new Intl.DateTimeFormat('pt-BR').format(d)
}
// ── config visual de status ────────────────────────────────────────────────────
const STATUS_CFG = {
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
cancelled: { label: 'Cancelado', severity: 'secondary' },
}
// ── computed: cenário a renderizar ────────────────────────────────────────────
const scenario = computed(() => {
if (props.evento.tipo !== 'sessao') return 'noop' // bloqueio
if (props.evento.billing_contract_id) return 'contrato' // pacote
if (fetching.value) return 'carregando'
if (record.value) return 'com-cobranca'
return 'sem-cobranca'
})
const canAct = computed(() =>
record.value && (record.value.status === 'pending' || record.value.status === 'overdue')
)
// ── buscar financial_record pelo evento ───────────────────────────────────────
async function fetchRecord () {
if (!props.evento.id) return
fetching.value = true
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
record.value = data ?? null
} catch (e) {
console.warn('[AgendaEventoFinanceiroPanel] fetchRecord:', e?.message)
record.value = null
} finally {
fetching.value = false
}
}
watch(() => props.evento?.id, () => {
record.value = null
fetchRecord()
}, { immediate: true })
// ── gerar cobrança ─────────────────────────────────────────────────────────────
async function onGerarCobranca () {
generating.value = true
try {
const result = await gerarCobrancaManual(props.evento)
if (!result.ok) throw new Error(result.error)
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'success', summary: 'Cobrança gerada', detail: `${fmtBRL(props.evento.price ?? 0)} agendado para recebimento.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível gerar a cobrança.', life: 4000 })
} finally {
generating.value = false
}
}
// ── dialog: registrar pagamento ───────────────────────────────────────────────
const payDlgVisible = ref(false)
const payDlgMethod = ref(null)
const payDlgLoading = ref(false)
function openPayDialog () {
payDlgMethod.value = null
payDlgVisible.value = true
}
async function confirmPayment () {
if (!payDlgMethod.value || !record.value) return
payDlgLoading.value = true
try {
const { data, error } = await supabase.rpc('mark_as_paid', {
p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value,
})
if (error) throw error
payDlgVisible.value = false
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'success', summary: 'Pago!', detail: `Recebimento via ${paymentLabel(payDlgMethod.value)} registrado.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível registrar pagamento.', life: 4000 })
} finally {
payDlgLoading.value = false
}
}
// ── cancelar cobrança ─────────────────────────────────────────────────────────
function requestCancel () {
confirm.require({
message: `Cancelar a cobrança de ${fmtBRL(record.value?.final_amount)} desta sessão?`,
header: 'Cancelar cobrança',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Não',
acceptLabel: 'Sim, cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
const { error } = await supabase
.from('financial_records')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', record.value.id)
if (error) throw error
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Cobrança cancelada.', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 })
}
},
})
}
</script>
<template>
<div>
<!-- Painel principal noop (bloqueios) não renderiza nada -->
<div v-if="scenario !== 'noop'" class="fin-panel">
<!-- Cabeçalho do painel -->
<div class="fin-panel__header">
<i class="pi pi-wallet" />
<span>Cobrança</span>
<Button
v-if="props.evento.billed && !fetching"
icon="pi pi-refresh"
text
size="small"
severity="secondary"
class="ml-auto h-6 w-6"
v-tooltip.top="'Recarregar'"
@click="fetchRecord"
/>
</div>
<!-- Sessão de pacote / contrato -->
<div v-if="scenario === 'contrato'" class="fin-panel__body">
<span class="fin-badge fin-badge--contract">
<i class="pi pi-box text-xs" />
Sessão de pacote
</span>
</div>
<!-- Sem cobrança gerada -->
<div v-else-if="scenario === 'sem-cobranca'" class="fin-panel__body fin-panel__body--empty">
<div class="flex items-center gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-minus-circle text-sm opacity-50" />
<span class="text-sm">Sem cobrança gerada</span>
</div>
<Button
label="Gerar cobrança"
icon="pi pi-plus"
size="small"
class="rounded-full mt-2"
:loading="generating || finLoading"
@click="onGerarCobranca"
/>
<div v-if="props.evento.price" class="text-xs text-[var(--text-color-secondary)] mt-1">
Valor da sessão: {{ fmtBRL(props.evento.price) }}
</div>
</div>
<!-- Carregando o financial_record -->
<div v-else-if="scenario === 'carregando'" class="fin-panel__body">
<div class="flex flex-col gap-1.5">
<Skeleton height="1rem" class="w-24" />
<Skeleton height="1.5rem" class="w-32" />
<Skeleton height="1rem" class="w-20" />
</div>
</div>
<!-- Com cobrança -->
<div v-else-if="scenario === 'com-cobranca'" class="fin-panel__body">
<!-- Linha de status + valor -->
<div class="flex items-center justify-between gap-2">
<Tag
:value="STATUS_CFG[record.status]?.label ?? record.status"
:severity="STATUS_CFG[record.status]?.severity"
class="text-xs"
/>
<span class="font-bold text-sm text-[var(--text-color)]">{{ fmtBRL(record.final_amount) }}</span>
</div>
<!-- Vencimento / data de pagamento -->
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] mt-1.5">
<template v-if="record.status === 'paid'">
<i class="pi pi-check-circle text-emerald-500" />
<span class="text-emerald-600">{{ paymentLabel(record.payment_method) }} · {{ fmtDate(record.paid_at) }}</span>
</template>
<template v-else>
<i class="pi pi-calendar" />
<span :class="record.status === 'overdue' ? 'text-red-500 font-semibold' : ''">
Vence {{ fmtDate(record.due_date) }}
</span>
</template>
</div>
<!-- Ações: pendente / vencido -->
<div v-if="canAct" class="flex gap-1.5 mt-3">
<Button
label="Receber"
icon="pi pi-check"
size="small"
class="rounded-full flex-1"
@click="openPayDialog"
/>
<Button
icon="pi pi-times"
size="small"
severity="danger"
outlined
class="rounded-full h-7 w-7"
v-tooltip.top="'Cancelar cobrança'"
@click="requestCancel"
/>
</div>
</div>
</div>
<!-- Dialog: Registrar Pagamento -->
<Dialog
v-model:visible="payDlgVisible"
modal
:draggable="false"
pt:mask:class="backdrop-blur-xs"
header="Registrar pagamento"
class="w-[92vw] max-w-sm"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Valor -->
<div class="flex items-center justify-between px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<span class="text-sm text-[var(--text-color-secondary)]">Valor a receber</span>
<span class="font-bold text-[var(--text-color)]">{{ fmtBRL(record?.final_amount) }}</span>
</div>
<!-- Método (grid de botões) -->
<div>
<div class="text-sm font-semibold text-[var(--text-color)] mb-2">Método de pagamento</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in PAYMENT_METHODS"
:key="opt.value"
type="button"
class="flex flex-col items-center gap-1 px-2 py-2 rounded-md border text-xs font-medium transition-all duration-150 cursor-pointer select-none"
:class="payDlgMethod === opt.value
? 'border-[var(--primary-color,#6366f1)] bg-[var(--primary-color,#6366f1)]/10 text-[var(--primary-color,#6366f1)]'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-[var(--primary-color,#6366f1)]/40'"
@click="payDlgMethod = opt.value"
>
<i
class="text-base"
:class="{
'pi pi-bolt': opt.value === 'pix',
'pi pi-building': opt.value === 'deposito',
'pi pi-money-bill': opt.value === 'dinheiro',
'pi pi-credit-card': opt.value === 'cartao',
'pi pi-id-card': opt.value === 'convenio',
}"
/>
{{ opt.label }}
</button>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="payDlgLoading" @click="payDlgVisible = false" />
<Button label="Confirmar" icon="pi pi-check" class="rounded-full" :loading="payDlgLoading" :disabled="!payDlgMethod" @click="confirmPayment" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.fin-panel {
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 0.5rem;
overflow: hidden;
background: var(--surface-card, #fff);
}
.fin-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground, #f8fafc);
border-bottom: 1px solid var(--surface-border, #e2e8f0);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.fin-panel__body {
padding: 0.75rem;
}
.fin-panel__body--empty {
display: flex;
flex-direction: column;
}
.fin-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
}
.fin-badge--contract {
background: color-mix(in srgb, var(--p-indigo-500, #6366f1) 10%, transparent);
color: var(--p-indigo-600, #4f46e5);
border: 1px solid color-mix(in srgb, var(--p-indigo-500, #6366f1) 20%, transparent);
}
</style>
@@ -1,4 +1,19 @@
<!-- src/components/agenda/AgendaOnlineGradeCard.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaOnlineGradeCard.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref } from 'vue'
import TabView from 'primevue/tabview'
@@ -1,4 +1,19 @@
<!-- src/components/agenda/AgendaSlotsPorDiaCard.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaSlotsPorDiaCard.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import TabView from 'primevue/tabview'
+16 -1
View File
@@ -1,4 +1,19 @@
<!-- src/components/agenda/PausasChipsEditor.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/PausasChipsEditor.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch } from 'vue'
import DatePicker from 'primevue/datepicker'
@@ -0,0 +1,284 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agendador/AgendadorPreview.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
const props = defineProps({
cfg: { type: Object, required: true }
})
const cor = computed(() => props.cfg.cor_primaria || '#4b6bff')
const corMix = computed(() => `color-mix(in srgb, ${cor.value} 15%, transparent)`)
const tipos = [
{ key: 'primeira', label: 'Primeira Entrevista', sub: 'Novo paciente', icon: 'pi-star', bg: '#0284c7', shadow: 'rgba(2,132,199,.25)' },
{ key: 'retorno', label: 'Retorno', sub: 'Já sou paciente', icon: 'pi-refresh', bg: '#059669', shadow: 'rgba(5,150,105,.25)' },
{ key: 'reagendar',label: 'Reagendar', sub: 'Mudar data ou horário', icon: 'pi-calendar-plus', bg: '#7c3aed', shadow: 'rgba(124,58,237,.25)' },
]
const tiposAtivos = computed(() =>
tipos.filter(t => props.cfg.tipos_habilitados?.includes(t.key))
)
const modalidadeLabel = computed(() => ({
presencial: 'Presencial',
online: 'Online (vídeo)',
ambos: 'Presencial · Online',
}[props.cfg.modalidade] || 'Presencial'))
</script>
<template>
<!-- Frame de celular -->
<div class="phone-frame">
<div class="phone-notch" />
<!-- Root do agendador -->
<div class="agdp-root">
<!-- Card principal -->
<div class="agdp-card" :style="{ '--cp': cor }">
<!-- Hero -->
<div class="agdp-hero">
<!-- Blobs -->
<div class="agdp-blobs" aria-hidden="true">
<div class="agdp-blob agdp-blob--1" :style="{ background: `color-mix(in srgb, ${cor} 22%, transparent)` }" />
<div class="agdp-blob agdp-blob--2" :style="{ background: `color-mix(in srgb, ${cor} 12%, transparent)` }" />
</div>
<!-- Logo -->
<div class="agdp-avatar">
<img v-if="cfg.logomarca_url" :src="cfg.logomarca_url" alt="logo" class="w-full h-full object-cover" />
<i v-else class="pi pi-heart text-xl" :style="{ color: cor }" />
</div>
<!-- Nome -->
<div class="agdp-name">
{{ cfg.nome_exibicao || 'Seu nome aqui' }}
</div>
<!-- Endereço -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-endereco">
<i class="pi pi-map-marker text-[0.6rem]" />
<span>{{ cfg.endereco }}</span>
</div>
<!-- Modalidade badge -->
<div class="agdp-badge" :style="{ background: corMix, color: cor }">
<i class="pi pi-video text-[0.55rem]" />
{{ modalidadeLabel }}
</div>
</div>
<!-- Home section -->
<div class="agdp-section">
<!-- Mensagem de boas-vindas -->
<p class="agdp-welcome">
{{ cfg.mensagem_boas_vindas || 'Bem-vindo! Escolha o tipo de consulta para começar.' }}
</p>
<!-- Como chegar -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-como-chegar">
<i class="pi pi-directions text-[0.6rem]" />
<span :style="{ color: cor }">Como chegar</span>
</div>
<div class="agdp-divider" />
<!-- Botões de tipo -->
<div class="agdp-tipos">
<div
v-for="t in tiposAtivos"
:key="t.key"
class="agdp-tipo-btn"
:style="{ '--btn-bg': t.bg, '--btn-shadow': t.shadow }"
>
<div class="agdp-tipo-icon">
<i :class="`pi ${t.icon} text-[0.65rem]`" />
</div>
<div class="agdp-tipo-label">
<span class="agdp-tipo-main">{{ t.label }}</span>
<span class="agdp-tipo-sub">{{ t.sub }}</span>
</div>
<i class="pi pi-arrow-right text-[0.55rem] opacity-40 ml-auto" />
</div>
<!-- Placeholder se nenhum tipo ativo -->
<div v-if="!tiposAtivos.length" class="agdp-no-tipos">
Nenhum tipo de consulta habilitado
</div>
</div>
<!-- Powered -->
<p class="agdp-powered">Powered by <strong>Agência Psi</strong></p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Frame de celular ──────────────────── */
.phone-frame {
position: relative;
width: 260px;
min-height: 500px;
margin: 0 auto;
border-radius: 2.5rem;
border: 8px solid #1e293b;
background: #1e293b;
box-shadow:
0 0 0 2px #334155,
0 32px 64px rgba(0,0,0,.35),
0 8px 24px rgba(0,0,0,.2);
overflow: hidden;
}
.phone-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 72px;
height: 10px;
background: #1e293b;
border-radius: 0 0 10px 10px;
z-index: 10;
}
/* ── Root ──────────────────────────────── */
.agdp-root {
background: #f0f3fb;
min-height: 100%;
padding: 12px 10px 16px;
overflow-y: auto;
max-height: 560px;
}
/* ── Card ──────────────────────────────── */
.agdp-card {
background: #fff;
border-radius: 1.25rem;
border: 1px solid rgba(0,0,0,.06);
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.04);
overflow: hidden;
}
/* ── Hero ──────────────────────────────── */
.agdp-hero {
position: relative;
overflow: hidden;
padding: 1.25rem 1rem 0.875rem;
background: #f7f8fd;
border-bottom: 1px solid rgba(0,0,0,.06);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.agdp-blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.agdp-blob { position: absolute; border-radius: 50%; filter: blur(40px); }
.agdp-blob--1 { width: 8rem; height: 8rem; top: -2rem; right: -2rem; }
.agdp-blob--2 { width: 7rem; height: 7rem; bottom: -2rem; left: -2rem; }
.agdp-avatar {
position: relative; z-index: 1;
width: 52px; height: 52px;
border-radius: 50%;
border: 2.5px solid #fff;
box-shadow: 0 4px 12px rgba(0,0,0,.12);
background: #f1f5f9;
overflow: hidden;
display: grid; place-items: center;
flex-shrink: 0;
}
.agdp-name {
position: relative; z-index: 1;
font-size: 0.82rem; font-weight: 800;
letter-spacing: -0.02em;
color: #111827;
text-align: center;
}
.agdp-endereco {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3px;
font-size: 0.62rem; color: #6b7280;
text-align: center;
max-width: 90%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.agdp-badge {
position: relative; z-index: 1;
font-size: 0.58rem; font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
display: flex; align-items: center; gap: 3px;
}
/* ── Section ───────────────────────────── */
.agdp-section { padding: 0.875rem 0.875rem 0.75rem; }
.agdp-welcome {
font-size: 0.68rem; color: #6b7280;
text-align: center; line-height: 1.5;
margin-bottom: 0.6rem;
}
.agdp-como-chegar {
display: flex; align-items: center; justify-content: center; gap: 3px;
font-size: 0.62rem; font-weight: 700;
margin-bottom: 0.6rem;
}
.agdp-divider {
height: 1px; background: #e5e7eb; margin-bottom: 0.75rem;
}
/* ── Tipos ─────────────────────────────── */
.agdp-tipos { display: flex; flex-direction: column; gap: 0.5rem; }
.agdp-tipo-btn {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-radius: 0.75rem;
border: 1.5px solid #e5e7eb;
background: #fff;
cursor: default;
transition: border-color .15s, box-shadow .15s;
}
.agdp-tipo-icon {
width: 28px; height: 28px;
border-radius: 0.5rem;
display: grid; place-items: center;
background: color-mix(in srgb, var(--btn-bg, #6366f1) 15%, transparent);
color: var(--btn-bg, #6366f1);
flex-shrink: 0;
}
.agdp-tipo-label { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.agdp-tipo-main { font-size: 0.72rem; font-weight: 700; color: #111827; }
.agdp-tipo-sub { font-size: 0.58rem; color: #9ca3af; }
.agdp-no-tipos {
font-size: 0.68rem; color: #9ca3af;
text-align: center; padding: 1rem 0;
}
.agdp-powered {
text-align: center; font-size: 0.58rem; color: #9ca3af;
margin-top: 1rem;
}
.agdp-powered strong { color: #6b7280; font-weight: 700; }
</style>
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/BestSellingWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref } from 'vue';
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/NotificationsWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref } from 'vue';
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/RecentSalesWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ProductService } from '@/services/ProductService';
import { onMounted, ref } from 'vue';
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/RevenueStreamWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { onMounted, ref, watch } from 'vue';
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/StatsWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/FeaturesWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div id="features" class="py-6 px-6 lg:px-20 mt-8 mx-0 lg:mx-20">
<div class="grid grid-cols-12 gap-4 justify-center">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/FooterWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="py-6 px-6 mx-0 mt-20 lg:mx-20">
<div class="grid grid-cols-12 gap-4">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/HeroWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div
id="hero"
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/HighlightsWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div id="highlights" class="py-6 px-6 lg:px-20 mx-0 my-12 lg:mx-20">
<div class="text-center">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/PricingWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div id="pricing" class="py-6 px-6 lg:px-20 my-2 md:my-6">
<div class="text-center mb-6">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/TopbarWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
function smoothScroll(id) {
document.body.click();
@@ -1,4 +1,19 @@
<!-- src/components/notifications/NotificationDrawer.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/notifications/NotificationDrawer.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -1,4 +1,19 @@
<!-- src/components/notifications/NotificationItem.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/notifications/NotificationItem.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
+16 -1
View File
@@ -1,4 +1,19 @@
<!-- src/components/patients/PatientActionMenu.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/patients/PatientActionMenu.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/security/FeatureGate.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
+87
View File
@@ -0,0 +1,87 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/AppLoadingPhrases.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 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'
defineProps({
action: { type: String, default: 'Carregando...' },
containerClass: { type: String, default: 'py-24' },
})
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...'
}
})
</script>
<style scoped>
/* Entrada do componente inteiro */
.fade-up-enter-active {
transition: opacity 0.45s ease, transform 0.45s ease;
}
.fade-up-enter-from {
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;
}
@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%; }
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/LoadedPhraseBlock.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 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'
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
}
})
</script>
+197
View File
@@ -0,0 +1,197 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/PatientCadastroDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
|
| Dialog de cadastro/edição de paciente.
| Abre PatientsCadastroPage em modo dialog (sem navegação de rota).
|
| Props:
| modelValue (Boolean) controla visibilidade
| patientId (String) null = novo, id = edição
|
| Emits:
| update:modelValue fecha
| 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 ( 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 ( 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: "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'
const props = defineProps({
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)
})
// Reset maximized when dialog opens
watch(() => props.modelValue, (v) => { if (!v) maximized.value = false })
const maximized = ref(false)
const pageRef = ref(null)
const pendingMode = ref('only')
const route = useRoute()
const router = useRouter()
const isOnPatientsPage = computed(() => {
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 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())
}
}
</script>
+208
View File
@@ -0,0 +1,208 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/PatientCreatePopover.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
|
| Popover de cadastro de paciente usado na página de pacientes e no
| menu lateral. Encapsula as 3 ações: Cadastro Rápido, Cadastro Completo
| e Link de Cadastro (com URL + copiar).
|
| Emits:
| quick-create usuário escolheu Cadastro Rápido
| go-complete usuário escolheu Cadastro Completo
|
| Expose:
| 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'
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 publicUrl = computed(() => {
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
}
} catch { /* silencioso */ } finally {
loadingToken.value = false
}
}
function toggle (event) {
popRef.value?.toggle(event)
loadToken()
}
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)
}
}
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 {}
}
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
defineExpose({ toggle, close })
</script>