ZERADO
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user