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

View File

@@ -1,5 +1,20 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/PatientsListPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -62,13 +77,19 @@
v-tooltip.top="'Descontos'"
@click="router.push('/configuracoes/descontos')"
/>
<SplitButton label="Novo" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" />
<PatientCadastroDialog
v-model="cadastroFullDialog"
:patient-id="editPatientId"
@created="onPatientCreated"
/>
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchMobileDlg = true" />
<Button icon="pi pi-user-plus" class="h-9 w-9 rounded-full" @click="goCreateFull" />
<Button icon="pi pi-user-plus" class="h-9 w-9 rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -123,59 +144,65 @@
<!-- Linha 2 (mobile) / parte direita (desktop): KPIs -->
<div class="flex gap-2 flex-1 flex-wrap xl:flex-nowrap">
<!-- Total -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1 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)]': filters.status === 'Todos' }"
@click="setStatus('Todos')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ kpis.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 5" :key="n" height="3.5rem" class="flex-1 min-w-[72px] rounded-md" />
</template>
<!-- Ativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Ativo'
? '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="setStatus('Ativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ kpis.active }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ativos</div>
</div>
<template v-else>
<!-- Total -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1 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)]': filters.status === 'Todos' }"
@click="setStatus('Todos')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ kpis.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<!-- Inativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Inativo'
? '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="setStatus('Inativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ kpis.inactive }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
</div>
<!-- Ativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Ativo'
? '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="setStatus('Ativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ kpis.active }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ativos</div>
</div>
<!-- Arquivados -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Arquivado'
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
@click="setStatus('Arquivado')"
>
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
</div>
<!-- Inativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Inativo'
? '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="setStatus('Inativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ kpis.inactive }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
</div>
<!-- Último atendimento não clicável -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
>
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Último atend.</div>
</div>
<!-- Arquivados -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Arquivado'
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
@click="setStatus('Arquivado')"
>
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
</div>
<!-- Último atendimento não clicável -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
>
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Último atend.</div>
</div>
</template>
</div>
</div>
@@ -395,7 +422,7 @@
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
scrollable
scrollHeight="flex"
scrollHeight="400px"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@@ -512,8 +539,21 @@
<!-- Cards mobile (<md) -->
<div class="md:hidden">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
<div v-if="loading" class="flex flex-col gap-3 pb-4">
<div v-for="n in 6" :key="n" class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<div class="flex items-center gap-3">
<Skeleton shape="square" size="3rem" border-radius="6px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="60%" height="14px" />
<Skeleton width="40%" height="11px" />
</div>
<Skeleton width="50px" height="22px" border-radius="999px" />
</div>
<div class="flex gap-2 justify-end">
<Skeleton width="90px" height="30px" border-radius="999px" />
<Skeleton width="80px" height="30px" border-radius="999px" />
</div>
</div>
</div>
<div v-else-if="filteredRows.length === 0" class="py-10 text-center">
@@ -620,8 +660,8 @@
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
<div v-if="loading" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<Skeleton v-for="n in 6" :key="n" height="8rem" class="rounded-xl" />
</div>
<!-- Empty -->
@@ -642,38 +682,48 @@
class="rounded-xl border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col shadow-sm hover:shadow-md transition-shadow duration-200"
>
<!-- Barra de cor do grupo -->
<div class="h-1.5 w-full" :style="grpColorStyle(grp.color)" />
<div class="h-1.5 w-full" :style="grpColorStyle(grp)" />
<!-- Header do grupo -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)]">
<!-- Header do grupo (clicável) -->
<button
class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)] w-full text-left bg-transparent border-0 border-b cursor-pointer transition-opacity duration-150 hover:opacity-80"
style="border-bottom-width: 1px"
:style="{ borderBottomColor: `${grp.color || 'var(--surface-border)'}30` }"
@click="openGrpDialog(grp)"
>
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-sm"
:style="grpColorStyle(grp.color)"
:style="grpColorStyle(grp)"
>
{{ (grp.name || '?')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[var(--text-color)] truncate text-sm">{{ grp.name }}</div>
<div class="font-semibold truncate text-sm" :style="{ color: grp.color || 'var(--text-color)' }">{{ grp.name }}</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }}
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }} · clique para ver
</div>
</div>
<span
class="inline-flex items-center justify-center min-w-[26px] h-6 px-1.5 rounded-full text-white text-xs font-bold flex-shrink-0"
:style="grpColorStyle(grp.color)"
:style="grpColorStyle(grp)"
>{{ grp.patients.length }}</span>
</div>
</button>
<!-- Chips de pacientes -->
<div class="p-3 flex flex-wrap gap-1.5 flex-1">
<button
v-for="p in grp.patients.slice(0, 12)"
:key="p.id"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-xs text-[var(--text-color)] hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-transparent cursor-pointer transition-all duration-150 font-medium group"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-xs text-[var(--text-color)] cursor-pointer transition-all duration-150 font-medium group"
v-tooltip.top="p.nome_completo"
@click="goEdit(p)"
@mouseenter="(e) => { e.currentTarget.style.background = grp.color || 'var(--primary-color,#6366f1)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'transparent' }"
@mouseleave="(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = ''; e.currentTarget.style.borderColor = '' }"
>
<span class="w-5 h-5 rounded-full bg-indigo-500/15 text-indigo-600 group-hover:bg-white/20 group-hover:text-white flex items-center justify-center text-[9px] font-bold flex-shrink-0 transition-colors">
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold flex-shrink-0 transition-colors group-hover:bg-white/20 group-hover:text-white"
:style="grpChipAvatarStyle(grp)"
>
{{ (p.nome_completo || '?').charAt(0).toUpperCase() }}
</span>
<span class="max-w-[120px] truncate">{{ (p.nome_completo || '—').split(' ').slice(0, 2).join(' ') }}</span>
@@ -689,6 +739,153 @@
</TabPanels>
</Tabs>
<div class="px-3 md:px-4 pb-5">
<LoadedPhraseBlock v-if="hasLoaded" />
</div>
<!--
Dialog: Pacientes do grupo
-->
<Dialog
v-model:visible="grpDialog.open"
modal
:draggable="false"
:style="{ width: '780px', maxWidth: '95vw' }"
:pt="{
root: { style: `border: 4px solid ${grpDialogHex()}` },
header: { style: `border-bottom: 1px solid ${grpDialogHex()}30` }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base flex-shrink-0"
:style="grpColorStyle(grpDialog.group)"
>
{{ (grpDialog.group?.name || '?')[0].toUpperCase() }}
</div>
<div>
<div class="text-[1rem] font-bold" :style="{ color: grpDialogHex() }">
Grupo {{ grpDialog.group?.name }}
</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
{{ grpDialog.group?.patients?.length || 0 }} paciente{{ (grpDialog.group?.patients?.length || 0) !== 1 ? 's' : '' }}
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<!-- Busca + contador -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<IconField class="w-full sm:w-72">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="grpDialog.search" placeholder="Buscar paciente..." class="w-full" />
</IconField>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
:style="{ background: `${grpDialogHex()}18`, color: grpDialogHex() }"
>{{ grpDialogFiltered.length }} paciente(s)</span>
</div>
<!-- Empty -->
<div v-if="grpDialogFiltered.length === 0" class="py-10 text-center">
<div
class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md"
:style="{ background: `${grpDialogHex()}18`, color: grpDialogHex() }"
>
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
{{ grpDialog.search ? 'Nenhum paciente corresponde à busca.' : 'Este grupo não possui pacientes associados.' }}
</div>
<Button
v-if="grpDialog.search"
class="mt-3 rounded-full"
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar"
size="small"
@click="grpDialog.search = ''"
/>
</div>
<!-- Tabela -->
<DataTable
v-else
:value="grpDialogFiltered"
dataKey="id"
stripedRows
responsiveLayout="scroll"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
>
<Column header="Paciente" sortable sortField="nome_completo">
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar
v-if="data.avatar_url"
:image="data.avatar_url"
shape="circle"
/>
<Avatar
v-else
:label="(data.nome_completo || '?').charAt(0).toUpperCase()"
shape="circle"
:style="{ background: `${grpDialogHex()}25`, color: grpDialogHex() }"
/>
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email_principal || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-[var(--text-color-secondary)]">{{ data.telefone || '—' }}</span>
</template>
</Column>
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
:style="{ borderColor: grpDialogHex(), color: grpDialogHex() }"
@click="goEdit(data); grpDialog.open = false"
/>
</template>
</Column>
<template #empty>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum resultado</div>
</div>
</template>
</DataTable>
</div>
<template #footer>
<Button
label="Fechar"
icon="pi pi-times"
outlined
class="rounded-full"
:style="{ borderColor: grpDialogHex(), color: grpDialogHex() }"
@click="grpDialog.open = false"
/>
</template>
</Dialog>
<!-- MODAL: CADASTRO RÁPIDO -->
<ComponentCadastroRapido
v-model="quickDialog"
@@ -705,6 +902,7 @@
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
@edit="(id) => { closeProntuario(); goEdit({ id }) }"
/>
<ConfirmDialog />
@@ -775,7 +973,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
@@ -788,6 +986,9 @@ import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue'
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue'
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
import { getSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js'
// ── Descontos por paciente ────────────────────────────────────────
const discountMap = ref({})
@@ -865,6 +1066,7 @@ 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() },
{ label: 'Link de Cadastro', icon: 'pi pi-link', command: () => router.push('/therapist/patients/link-externo') },
{ separator: true },
{ label: 'Descontos por Paciente', icon: 'pi pi-percentage', command: () => router.push('/configuracoes/descontos') },
{ separator: true },
@@ -874,10 +1076,15 @@ const patMobileMenuItems = [
const uid = ref(null)
const loading = ref(false)
const hasLoaded = ref(false)
const showAdvanced = ref(false)
const quickDialog = ref(false)
const searchMobileDlg = ref(false)
const quickDialog = ref(false)
const cadastroFullDialog = ref(false)
const editPatientId = ref(null)
const dialogSaved = ref(false)
const searchMobileDlg = ref(false)
const createPopoverRef = ref(null)
const prontuarioOpen = ref(false)
const selectedPatient = ref(null)
@@ -947,10 +1154,6 @@ const hasActiveFilters = computed(() => Boolean(
filters.createdFrom || filters.createdTo
))
const createMenu = [
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() }
]
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
@@ -996,6 +1199,18 @@ function openProntuario (row) {
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
function openQuickCreate () { quickDialog.value = true }
function onPatientCreated () { dialogSaved.value = true }
watch(cadastroFullDialog, async (isOpen) => {
if (!isOpen) {
editPatientId.value = null
if (dialogSaved.value) {
dialogSaved.value = false
await fetchAll()
}
}
})
function onQuickCreated (row) {
if (!row) return
patients.value = [
@@ -1048,8 +1263,8 @@ function safePush (toObj, 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; const r = getPatientsRoutes(); return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(row.id)) }
function goCreateFull() { cadastroFullDialog.value = true }
function goEdit (row) { if (!row?.id) return; editPatientId.value = String(row.id); cadastroFullDialog.value = true }
// ── Filters & Sort ────────────────────────────────────────
let searchTimer = null
@@ -1204,7 +1419,7 @@ async function fetchAll () {
updateKpis()
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui carregar pacientes.', life: 3500 })
} finally { loading.value = false }
} finally { loading.value = false; hasLoaded.value = true }
}
async function listPatients () {
@@ -1340,7 +1555,13 @@ const groupedPatientsView = computed(() => {
const all = patients.value || []
const grpMap = new Map()
for (const g of (groups.value || [])) {
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: g.color || g.cor, patients: [], isSystem: !!g.is_system })
const isSystem = !!g.is_system
const storedColor = isSystem ? getSysGroupColor(g.id) : null
const rawColor = storedColor || g.color || g.cor || null
const resolvedColor = rawColor
? (rawColor.startsWith('#') ? rawColor : `#${rawColor}`)
: (isSystem ? `#${systemDefaultColorForGrp(g.name || g.nome)}` : null)
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: resolvedColor, patients: [], isSystem })
}
const ungrouped = { id: '__none__', name: 'Sem grupo', color: null, patients: [], isSystem: false }
for (const p of all) {
@@ -1358,9 +1579,46 @@ const groupedPatientsView = computed(() => {
return result
})
function grpColorStyle (color) {
if (!color) return { background: 'var(--surface-border)' }
return { background: color.startsWith('#') ? color : `#${color}` }
function systemDefaultColorForGrp (nameOrObj) {
const name = typeof nameOrObj === 'string' ? nameOrObj : (nameOrObj?.name || nameOrObj?.nome || '')
return getSystemGroupDefaultColor(name).replace('#', '')
}
function grpColorStyle (grp) {
// aceita string (#hex) ou objeto grp com .color já resolvido
const hex = typeof grp === 'string' ? (grp || null) : (grp?.color || null)
if (!hex) return { background: 'var(--surface-border)' }
return { background: hex }
}
function grpChipAvatarStyle (grp) {
const hex = typeof grp === 'string' ? (grp || null) : (grp?.color || null)
if (!hex) return {}
return { background: `${hex}25`, color: hex }
}
// ── Dialog: grupo de pacientes ────────────────────────────
const grpDialog = reactive({ open: false, group: null, search: '' })
const grpDialogFiltered = computed(() => {
const list = grpDialog.group?.patients || []
const s = String(grpDialog.search || '').trim().toLowerCase()
if (!s) return list
return list.filter(p =>
String(p.nome_completo || '').toLowerCase().includes(s) ||
String(p.email_principal || '').toLowerCase().includes(s) ||
String(p.telefone || '').toLowerCase().includes(s)
)
})
function openGrpDialog (grp) {
grpDialog.group = grp
grpDialog.search = ''
grpDialog.open = true
}
function grpDialogHex () {
return grpDialog.group?.color || '#6366f1'
}
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000