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
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/PatientsCadastroPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -7,6 +23,12 @@ import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const props = defineProps({
dialogMode: { type: Boolean, default: false },
patientId: { type: String, default: null }
})
const emit = defineEmits(['cancel', 'created'])
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
@@ -91,8 +113,12 @@ onBeforeUnmount(() => {
})
// ── Route helpers ─────────────────────────────────────────
const patientId = computed(() => String(route.params?.id || '').trim() || null)
const isEdit = computed(() => !!patientId.value)
const patientId = computed(() =>
props.dialogMode
? (props.patientId || null)
: (String(route.params?.id || '').trim() || null)
)
const isEdit = computed(() => !!patientId.value)
function getAreaKey () {
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
@@ -121,6 +147,7 @@ async function safePush (toNameObj, fallbackPath) {
}
function goBack () {
if (props.dialogMode) { emit('cancel'); return }
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
@@ -363,8 +390,7 @@ async function fetchAll () {
} finally { loading.value = false }
}
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
watch(patientId, fetchAll, { immediate: true })
// ── Tenant resolve ────────────────────────────────────────
async function resolveTenantContextOrFail () {
@@ -393,13 +419,16 @@ async function onSubmit () {
await maybeUploadAvatar(ownerId, patientId.value)
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 }); return
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
if (props.dialogMode) { emit('created', { id: patientId.value }); return }
return
}
const created = await createPatient(payload)
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
if (props.dialogMode) { emit('created', created); return }
form.value = resetForm(); grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []
avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = ''
await openPanel(0)
@@ -422,7 +451,9 @@ async function doDelete () {
const { error: e1 } = await supabase.from('patient_group_patient').delete().eq('patient_id', pid); if (e1) throw e1
const { error: e2 } = await supabase.from('patient_patient_tag').delete().eq('patient_id', pid); if (e2) throw e2
const { error: e3 } = await supabase.from('patients').delete().eq('id', pid); if (e3) throw e3
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 }); goBack()
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 })
if (props.dialogMode) { emit('created', null); return }
goBack()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
} finally { deleting.value = false }
@@ -509,19 +540,21 @@ async function createTagPersist () {
createTagError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao criar tag.')
} finally { createTagSaving.value = false }
}
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
</script>
<template>
<Toast />
<ConfirmDialog />
<ConfirmDialog v-if="!dialogMode" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
HERO sticky
HERO sticky (oculto no modo dialog)
-->
<section
v-if="!dialogMode"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
@@ -552,8 +585,8 @@ async function createTagPersist () {
<!-- Espaçador -->
<div class="flex-1" />
<!-- Ações -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<!-- Ações (ocultas no modo dialog o Dialog tem seu próprio footer) -->
<div v-if="!dialogMode" class="flex items-center gap-1.5 flex-shrink-0">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo"
@@ -593,7 +626,10 @@ async function createTagPersist () {
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[260px_1fr] max-w-[1100px] mx-auto">
<!-- SIDEBAR -->
<aside class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)] xl:self-start">
<aside
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:self-start"
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)]'"
>
<!-- Avatar -->
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2">
@@ -868,8 +904,8 @@ async function createTagPersist () {
</Accordion>
<!-- Botão salvar bottom -->
<div class="mt-4 flex justify-center">
<!-- Botão salvar bottom (oculto no modo dialog o footer cuida disso) -->
<div v-if="!dialogMode" class="mt-4 flex justify-center">
<Button label="Salvar" icon="pi pi-check" :loading="saving" class="min-w-[200px] rounded-full" @click="onSubmit" />
</div>
</div>
@@ -1,5 +1,20 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/PatientsExternalLinkPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -172,6 +187,8 @@
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
</div>
</div>
<LoadedPhraseBlock v-if="inviteToken" />
</div>
<!-- DIREITA: instruções -->
@@ -223,6 +240,7 @@
</div>
</div>
</div>
</template>
<script setup>
@@ -1,4 +1,19 @@
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { supabase } from '@/lib/supabase/client'
@@ -19,6 +34,7 @@ const tenantStore = useTenantStore()
const converting = ref(false)
const loading = ref(false)
const hasLoaded = ref(false)
const rows = ref([])
const q = ref('')
@@ -262,6 +278,7 @@ async function fetchIntakes () {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
} finally {
loading.value = false
hasLoaded.value = true
}
}
@@ -404,7 +421,6 @@ onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
@@ -499,47 +515,52 @@ onBeforeUnmount(() => { _observer?.disconnect() })
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
@click="toggleStatusFilter('')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[72px] rounded-md" />
</template>
<template v-else>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
@click="toggleStatusFilter('')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'new'
? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]'
: 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
@click="toggleStatusFilter('new')"
>
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'new'
? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]'
: 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
@click="toggleStatusFilter('new')"
>
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'converted'
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="toggleStatusFilter('converted')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'converted'
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="toggleStatusFilter('converted')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'rejected'
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="toggleStatusFilter('rejected')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'rejected'
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="toggleStatusFilter('rejected')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
</div>
</template>
</div>
<!--
@@ -635,8 +656,21 @@ onBeforeUnmount(() => { _observer?.disconnect() })
CARDS mobile (<md)
-->
<div class="md:hidden mx-3 mb-5">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
<div v-if="loading" class="flex flex-col gap-2.5">
<div v-for="n in 5" :key="n" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3.5 flex flex-col gap-3">
<div class="flex items-center gap-3">
<Skeleton shape="circle" size="2.5rem" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="55%" height="13px" />
<Skeleton width="40%" height="11px" />
</div>
<Skeleton width="60px" height="22px" border-radius="999px" />
</div>
<div class="flex items-center justify-between">
<Skeleton width="30%" height="11px" />
<Skeleton width="60px" height="28px" border-radius="999px" />
</div>
</div>
</div>
<div v-else-if="filteredRows.length === 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] py-10 text-center">
@@ -681,6 +715,10 @@ onBeforeUnmount(() => { _observer?.disconnect() })
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="hasLoaded" />
</div>
<!--
MODAL detalhe
-->