Copyright, Financeiro, Lançamentos, aprimoramentos de ui
This commit is contained in:
@@ -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