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
+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>