Copyright, Financeiro, Lançamentos, aprimoramentos de ui
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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: só "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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 (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'
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user