Copyright, Financeiro, Lançamentos, aprimoramentos de ui
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user