This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions

View File

@@ -1,139 +1,112 @@
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<Toast />
<div class="relative flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<!-- título -->
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-users text-lg" />
</div>
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="pat-sentinel" />
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-xl font-semibold leading-none">Pacientes</div>
<Tag :value="`${kpis.total}`" severity="secondary" />
</div>
<div class="mt-1 text-sm text-color-secondary">
Lista de pacientes cadastrados. Filtre por status, tags e grupos.
</div>
</div>
</div>
<!-- Hero Header sticky -->
<div ref="headerEl" class="pat-hero mx-3 md:mx-5 mb-4" :class="{ 'pat-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="pat-hero__blobs" aria-hidden="true">
<div class="pat-hero__blob pat-hero__blob--1" />
<div class="pat-hero__blob pat-hero__blob--2" />
<div class="pat-hero__blob pat-hero__blob--3" />
</div>
<!-- KPIs como filtros -->
<div class="mt-4 flex flex-wrap gap-2">
<Button
type="button"
class="!rounded-full"
:outlined="filters.status !== 'Todos'"
severity="secondary"
@click="setStatus('Todos')"
>
<span class="flex items-center gap-2">
<i class="pi pi-users" />
Total: <b>{{ kpis.total }}</b>
</span>
</Button>
<!-- Linha 1: brand + controles -->
<div class="pat-hero__row1">
<div class="pat-hero__brand">
<div class="pat-hero__icon">
<i class="pi pi-users text-lg" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="pat-hero__title">Pacientes</div>
<Tag :value="`${kpis.total}`" severity="secondary" />
</div>
<div class="pat-hero__sub">Lista de pacientes cadastrados. Filtre por status, tags e grupos.</div>
</div>
</div>
<Button
type="button"
class="!rounded-full"
:outlined="filters.status !== 'Ativo'"
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
@click="setStatus('Ativo')"
>
<span class="flex items-center gap-2">
<i class="pi pi-user-plus" />
Ativos: <b>{{ kpis.active }}</b>
</span>
</Button>
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchAll" />
<SplitButton label="Cadastrar" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
</div>
<Button
type="button"
class="!rounded-full"
:outlined="filters.status !== 'Inativo'"
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
@click="setStatus('Inativo')"
>
<span class="flex items-center gap-2">
<i class="pi pi-user-minus" />
Inativos: <b>{{ kpis.inactive }}</b>
</span>
</Button>
<!-- Menu mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
<Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
</div>
</div>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-color-secondary">
<i class="pi pi-calendar" />
Último atendimento: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
</span>
</div>
</div>
<!-- Divisor -->
<Divider class="pat-hero__divider my-2" />
<!-- ações -->
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
<span class="p-input-icon-left w-full sm:w-[360px]">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filters.search"
class="w-full"
placeholder="Buscar por nome, e-mail ou telefone…"
@input="onFilterChangedDebounced"
/>
</IconField>
</FloatLabel>
</span>
<!-- Linha 2: KPI filtros (oculta no mobile) -->
<div class="pat-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
size="small"
class="!rounded-full"
:outlined="filters.status !== 'Todos'"
severity="secondary"
@click="setStatus('Todos')"
>
<span class="flex items-center gap-1.5">
<i class="pi pi-users text-xs" />
Total: <b>{{ kpis.total }}</b>
</span>
</Button>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchAll"
/>
<Button
type="button"
size="small"
class="!rounded-full"
:outlined="filters.status !== 'Ativo'"
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
@click="setStatus('Ativo')"
>
<span class="flex items-center gap-1.5">
<i class="pi pi-user-plus text-xs" />
Ativos: <b>{{ kpis.active }}</b>
</span>
</Button>
<SplitButton
label="Cadastrar"
icon="pi pi-user-plus"
:model="createMenu"
@click="goCreateFull"
/>
</div>
</div>
<Button
type="button"
size="small"
class="!rounded-full"
:outlined="filters.status !== 'Inativo'"
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
@click="setStatus('Inativo')"
>
<span class="flex items-center gap-1.5">
<i class="pi pi-user-minus text-xs" />
Inativos: <b>{{ kpis.inactive }}</b>
</span>
</Button>
<!-- chips de filtros ativos (micro-UX) -->
<div v-if="hasActiveFilters" class="relative mt-4 flex flex-wrap items-center gap-2">
<span class="text-xs text-color-secondary">Filtros:</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1.5 text-xs text-color-secondary">
<i class="pi pi-calendar" />
Último atend.: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
</span>
</div>
</div>
</div>
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
<Button
label="Limpar"
icon="pi pi-filter-slash"
severity="danger"
outlined
size="small"
class="!rounded-full"
@click="clearAllFilters"
/>
</div>
</div>
</div>
<!-- Chips de filtros ativos (fora do hero) -->
<div v-if="hasActiveFilters" class="mx-3 md:mx-5 mb-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-color-secondary">Filtros:</span>
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
</div>
<!-- KPI Cards
@@ -209,7 +182,7 @@
</div> -->
<!-- TABS (placeholder para evoluir depois) -->
<Tabs value="pacientes" class="mt-3">
<Tabs value="pacientes" class="px-3 md:px-5 mb-5">
<TabList>
<Tab value="pacientes"><i class="pi pi-users mr-2" />Pacientes</Tab>
<Tab value="espera"><i class="pi pi-hourglass mr-2" />Lista de espera</Tab>
@@ -403,163 +376,170 @@
</Transition>
</div>
<!-- Table -->
<DataTable
:value="filteredRows"
dataKey="id"
:loading="loading"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
responsiveLayout="scroll"
scrollable
scrollHeight="flex"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@sort="onSort"
>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
Tente limpar filtros ou mudar o termo de busca.
</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
</div>
</div>
</template>
<Column
:key="'col-paciente'"
field="nome_completo"
header="Paciente"
v-if="isColVisible('paciente')"
sortable
>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar
v-if="data.avatar_url"
:image="data.avatar_url"
shape="square"
size="large"
/>
<Avatar
v-else
:label="initials(data.nome_completo)"
shape="square"
size="large"
/>
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
<!-- Table desktop (md+) -->
<div class="hidden md:block">
<DataTable
:value="filteredRows"
dataKey="id"
:loading="loading"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
scrollable
scrollHeight="flex"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@sort="onSort"
>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
</div>
</div>
</template>
</Column>
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }">
<Tag
:value="data.status"
:severity="data.status === 'Ativo' ? 'success' : 'danger'"
/>
</template>
</Column>
<Column :key="'col-paciente'" field="nome_completo" header="Paciente" v-if="isColVisible('paciente')" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
<template #body="{ data }">
<div class="text-sm leading-tight">
<div class="font-medium">
{{ fmtPhoneBR(data.telefone) }}
</div>
<div class="text-xs text-color-secondary">
{{ data.email_principal || '—' }}
</div>
</div>
</template>
</Column>
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
</template>
</Column>
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
<template #body="{ data }">
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
</template>
</Column>
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
<template #body="{ data }">
<div class="text-sm leading-tight">
<div class="font-medium">{{ fmtPhoneBR(data.telefone) }}</div>
<div class="text-xs text-color-secondary">{{ data.email_principal || '—' }}</div>
</div>
</template>
</Column>
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
</Column>
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
<template #body="{ data }">
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
</template>
</Column>
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.created_at || '—' }}</template>
</Column>
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
</Column>
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
<template #body="{ data }">
<div v-if="!(data.groups || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag
v-for="g in data.groups"
:key="g.id"
:value="g.name"
:style="chipStyle(g.color)"
/>
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
<template #body="{ data }">{{ data.created_at || '—' }}</template>
</Column>
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
<template #body="{ data }">
<div v-if="!(data.groups || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag v-for="g in data.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
</div>
</template>
</Column>
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
<template #body="{ data }">
<div v-if="!(data.tags || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag v-for="t in data.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
</div>
</template>
</Column>
<Column :key="'col-acoes'" header="Ações" style="width: 16rem;" frozen alignFrozen="right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Cards mobile (<md) -->
<div class="md:hidden">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<div v-else-if="filteredRows.length === 0" class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar" @click="goCreateFull" />
</div>
</div>
<div v-else class="flex flex-col gap-3 pb-4">
<div
v-for="pat in filteredRows"
:key="pat.id"
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<!-- Topo: avatar + nome + status -->
<div class="flex items-center gap-3">
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{{ pat.nome_completo }}</div>
<div class="text-xs text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div>
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
</div>
</template>
</Column>
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
<template #body="{ data }">
<div v-if="!(data.tags || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag
v-for="t in data.tags"
:key="t.id"
:value="t.name"
:style="chipStyle(t.color)"
/>
<!-- Grupos + Tags -->
<div v-if="(pat.groups || []).length || (pat.tags || []).length" class="mt-3 flex flex-wrap gap-1.5">
<Tag v-for="g in pat.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
<Tag v-for="t in pat.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
</div>
</template>
</Column>
<Column
:key="'col-acoes'"
header="Ações"
style="width: 16rem;"
frozen
alignFrozen="right"
>
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
</div>
</template>
</Column>
</DataTable>
<!-- Ações -->
<div class="mt-3 flex gap-2 justify-end">
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
</div>
</div>
</div>
</div>
<div class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-xs text-color-secondary">
<div>
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
<span v-if="hasActiveFilters"> (filtrado)</span>
</div>
<div class="hidden md:block">
Dica: clique em Ativos/Inativos no topo para filtrar rápido.
</div>
</div>
<div>
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
<span v-if="hasActiveFilters"> (filtrado)</span>
</div>
<div class="hidden md:block">
Dica: clique em Ativos/Inativos" no topo para filtrar rápido.
</div>
</div>
</TabPanel>
@@ -604,18 +584,19 @@
@close="closeProntuario"
/>
<ConfirmDialog />
</div>
<ConfirmDialog />
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import MultiSelect from 'primevue/multiselect'
import Popover from 'primevue/popover'
import Menu from 'primevue/menu'
import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
@@ -642,6 +623,22 @@ function getAreaBase() {
const toast = useToast()
const confirm = useConfirm()
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
const patMobileMenuRef = ref(null)
const patMobileMenuItems = [
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
]
const uid = ref(null)
const loading = ref(false)
@@ -681,7 +678,7 @@ const lockedKeys = computed(() =>
columnCatalogAll.filter(c => c.locked).map(c => c.key)
)
// SEM mutar selectedColumns: apenas “projeta” as visíveis
// SEM mutar selectedColumns: apenas “projeta" as visíveis
const visibleKeys = computed(() => {
const set = new Set(selectedColumns.value || [])
lockedKeys.value.forEach(k => set.add(k))
@@ -751,10 +748,18 @@ const createMenu = [
]
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
await loadUser()
await fetchAll()
})
onBeforeUnmount(() => { _observer?.disconnect() })
function fmtPhoneBR(v) {
const d = String(v ?? '').replace(/\D/g, '')
if (!d) return '—'
@@ -815,37 +820,62 @@ function onQuickCreated(row) {
// -----------------------------
// Navigation (shared feature)
// -----------------------------
function goGroups() {
router.push(`${getAreaBase()}/patients/grupos`)
function getAreaKey () {
const seg = String(route.path || '').split('/')[1]
return seg === 'therapist' ? 'therapist' : 'admin'
}
function goCreateFull() {
router.push(`${getAreaBase()}/patients/cadastro`)
function getPatientsRoutes () {
const area = getAreaKey()
if (area === 'therapist') {
return {
groupsPath: '/therapist/patients/grupos',
createPath: '/therapist/patients/cadastro',
editPath: (id) => `/therapist/patients/cadastro/${id}`,
// se existir no seu router
createName: 'therapist-patients-cadastro',
editName: 'therapist-patients-cadastro-edit',
groupsName: 'therapist-patients-grupos'
}
}
// ✅ admin usa "pacientes" (PT-BR)
return {
groupsPath: '/admin/pacientes/grupos',
createPath: '/admin/pacientes/cadastro',
editPath: (id) => `/admin/pacientes/cadastro/${id}`,
// se existir no seu router (pelo que você mostrou antes, existe)
createName: 'admin-pacientes-cadastro',
editName: 'admin-pacientes-cadastro-edit',
groupsName: 'admin-pacientes-grupos'
}
}
function goEdit(row) {
function safePush (toObj, fallbackPath) {
try {
const r = router.resolve(toObj)
if (r?.matched?.length) return router.push(toObj)
} catch (_) {}
return router.push(fallbackPath)
}
function goGroups () {
const r = getPatientsRoutes()
return safePush({ name: r.groupsName }, r.groupsPath)
}
function goCreateFull () {
const r = getPatientsRoutes()
return safePush({ name: r.createName }, r.createPath)
}
function goEdit (row) {
if (!row?.id) return
router.push(`${getAreaBase()}/patients/cadastro/${row.id}`)
}
function setStatus(v) {
filters.status = v
onFilterChanged()
}
function clearAllFilters() {
filters.status = 'Todos'
filters.search = ''
filters.groupId = null
filters.tagId = null
filters.createdFrom = null
filters.createdTo = null
onFilterChanged()
}
function onSort(e) {
sort.field = e.sortField
sort.order = e.sortOrder
const r = getPatientsRoutes()
return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(row.id))
}
// -----------------------------
@@ -1188,7 +1218,7 @@ function confirmDeleteOne(row) {
const nome = row?.nome_completo || 'este paciente'
confirm.require({
header: 'Excluir paciente',
message: `Tem certeza que deseja excluir “${nome}?`,
message: `Tem certeza que deseja excluir “${nome}"?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
@@ -1237,17 +1267,65 @@ function updateKpis() {
</script>
<style scoped>
.kpi-card :deep(.p-card-body) {
padding: 1rem;
/* ── Hero Header ─────────────────────────────────── */
.pat-sentinel { height: 1px; }
.pat-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.pat-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
/* Blobs */
.pat-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pat-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.pat-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.pat-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.pat-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 30%; background: rgba(236,72,153,0.07); }
/* Linha 1 */
.pat-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.pat-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.pat-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.pat-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.pat-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 (oculta no mobile) */
.pat-hero__row2 {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
}
@media (max-width: 767px) {
.pat-hero__divider,
.pat-hero__row2 { display: none; }
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* KPI card */
.kpi-card :deep(.p-card-body) { padding: 1rem; }
/* Fade */
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>