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
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/cadastro/PatientsCadastroPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -7,6 +23,12 @@ import { useConfirm } from 'primevue/useconfirm'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const props = defineProps({
|
||||
dialogMode: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null }
|
||||
})
|
||||
const emit = defineEmits(['cancel', 'created'])
|
||||
|
||||
const { canSee } = useRoleGuard()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -91,8 +113,12 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// ── Route helpers ─────────────────────────────────────────
|
||||
const patientId = computed(() => String(route.params?.id || '').trim() || null)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
const patientId = computed(() =>
|
||||
props.dialogMode
|
||||
? (props.patientId || null)
|
||||
: (String(route.params?.id || '').trim() || null)
|
||||
)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
|
||||
@@ -121,6 +147,7 @@ async function safePush (toNameObj, fallbackPath) {
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
if (props.dialogMode) { emit('cancel'); return }
|
||||
const { listName, listPath } = getPatientsRoutes()
|
||||
if (window.history.length > 1) router.back()
|
||||
else safePush({ name: listName }, listPath)
|
||||
@@ -363,8 +390,7 @@ async function fetchAll () {
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => route.params?.id, fetchAll, { immediate: true })
|
||||
onMounted(fetchAll)
|
||||
watch(patientId, fetchAll, { immediate: true })
|
||||
|
||||
// ── Tenant resolve ────────────────────────────────────────
|
||||
async function resolveTenantContextOrFail () {
|
||||
@@ -393,13 +419,16 @@ async function onSubmit () {
|
||||
await maybeUploadAvatar(ownerId, patientId.value)
|
||||
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 }); return
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', { id: patientId.value }); return }
|
||||
return
|
||||
}
|
||||
const created = await createPatient(payload)
|
||||
await maybeUploadAvatar(ownerId, created.id)
|
||||
await replacePatientGroups(created.id, grupoIdSelecionado.value)
|
||||
await replacePatientTags(created.id, tagIdsSelecionadas.value)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', created); return }
|
||||
form.value = resetForm(); grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []
|
||||
avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = ''
|
||||
await openPanel(0)
|
||||
@@ -422,7 +451,9 @@ async function doDelete () {
|
||||
const { error: e1 } = await supabase.from('patient_group_patient').delete().eq('patient_id', pid); if (e1) throw e1
|
||||
const { error: e2 } = await supabase.from('patient_patient_tag').delete().eq('patient_id', pid); if (e2) throw e2
|
||||
const { error: e3 } = await supabase.from('patients').delete().eq('id', pid); if (e3) throw e3
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 }); goBack()
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', null); return }
|
||||
goBack()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
|
||||
} finally { deleting.value = false }
|
||||
@@ -509,19 +540,21 @@ async function createTagPersist () {
|
||||
createTagError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao criar tag.')
|
||||
} finally { createTagSaving.value = false }
|
||||
}
|
||||
|
||||
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
<ConfirmDialog v-if="!dialogMode" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky
|
||||
HERO sticky (oculto no modo dialog)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
v-if="!dialogMode"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
@@ -552,8 +585,8 @@ async function createTagPersist () {
|
||||
<!-- Espaçador -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<!-- Ações (ocultas no modo dialog — o Dialog tem seu próprio footer) -->
|
||||
<div v-if="!dialogMode" class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
@@ -593,7 +626,10 @@ async function createTagPersist () {
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[260px_1fr] max-w-[1100px] mx-auto">
|
||||
|
||||
<!-- ── SIDEBAR ──────────────────────────────────── -->
|
||||
<aside class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)] xl:self-start">
|
||||
<aside
|
||||
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:self-start"
|
||||
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)]'"
|
||||
>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2">
|
||||
@@ -868,8 +904,8 @@ async function createTagPersist () {
|
||||
|
||||
</Accordion>
|
||||
|
||||
<!-- Botão salvar bottom -->
|
||||
<div class="mt-4 flex justify-center">
|
||||
<!-- Botão salvar bottom (oculto no modo dialog — o footer cuida disso) -->
|
||||
<div v-if="!dialogMode" class="mt-4 flex justify-center">
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" class="min-w-[200px] rounded-full" @click="onSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/cadastro/PatientsExternalLinkPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
@@ -172,6 +187,8 @@
|
||||
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadedPhraseBlock v-if="inviteToken" />
|
||||
</div>
|
||||
|
||||
<!-- ── DIREITA: instruções ────────────────────────── -->
|
||||
@@ -223,6 +240,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
@@ -19,6 +34,7 @@ const tenantStore = useTenantStore()
|
||||
|
||||
const converting = ref(false)
|
||||
const loading = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const rows = ref([])
|
||||
const q = ref('')
|
||||
|
||||
@@ -262,6 +278,7 @@ async function fetchIntakes () {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
hasLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +421,6 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
@@ -499,47 +515,52 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
|
||||
@click="toggleStatusFilter('')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[72px] rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
|
||||
@click="toggleStatusFilter('')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="statusFilter === 'new'
|
||||
? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]'
|
||||
: 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="statusFilter === 'new'
|
||||
? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]'
|
||||
: 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="statusFilter === 'converted'
|
||||
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
|
||||
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="statusFilter === 'converted'
|
||||
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
|
||||
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="statusFilter === 'rejected'
|
||||
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
|
||||
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="statusFilter === 'rejected'
|
||||
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
|
||||
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
@@ -635,8 +656,21 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
CARDS — mobile (<md)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="md:hidden mx-3 mb-5">
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
<div v-if="loading" class="flex flex-col gap-2.5">
|
||||
<div v-for="n in 5" :key="n" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3.5 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Skeleton shape="circle" size="2.5rem" />
|
||||
<div class="flex flex-col gap-1.5 flex-1">
|
||||
<Skeleton width="55%" height="13px" />
|
||||
<Skeleton width="40%" height="11px" />
|
||||
</div>
|
||||
<Skeleton width="60px" height="22px" border-radius="999px" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Skeleton width="30%" height="11px" />
|
||||
<Skeleton width="60px" height="28px" border-radius="999px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredRows.length === 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] py-10 text-center">
|
||||
@@ -681,6 +715,10 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 md:px-4 pb-3">
|
||||
<LoadedPhraseBlock v-if="hasLoaded" />
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
MODAL detalhe
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/grupos/GruposPacientesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<PatientCadastroDialog
|
||||
v-model="editPatientDialog"
|
||||
:patient-id="editPatientId"
|
||||
/>
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
@@ -88,24 +108,29 @@
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
|
||||
}"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'text-green-500': s.cls === 'qs-ok',
|
||||
'text-[var(--text-color)]': !s.cls,
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
|
||||
}"
|
||||
>{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
:class="{
|
||||
'text-green-500': s.cls === 'qs-ok',
|
||||
'text-[var(--text-color)]': !s.cls,
|
||||
}"
|
||||
>{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
@@ -163,7 +188,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-[3px] h-5 rounded-sm flex-shrink-0"
|
||||
:style="data.cor ? colorStyle(data.cor) : { background: 'var(--surface-border)' }"
|
||||
:style="effectiveCor(data) ? colorStyle(effectiveCor(data)) : { background: 'var(--surface-border)' }"
|
||||
/>
|
||||
<span class="font-medium">{{ data.nome }}</span>
|
||||
</div>
|
||||
@@ -195,7 +220,7 @@
|
||||
<div class="flex gap-1.5 justify-end">
|
||||
<Button v-if="!data.is_system" icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
|
||||
<Button v-if="!data.is_system" icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
<Button v-if="data.is_system" icon="pi pi-lock" outlined rounded size="small" disabled v-tooltip.top="'Grupo padrão — inalterável'" />
|
||||
<Button v-if="data.is_system" icon="pi pi-palette" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar cor'" @click="openEditColor(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -215,6 +240,7 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: grupos com pacientes ─────────── -->
|
||||
@@ -236,8 +262,13 @@
|
||||
>{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-2 p-3">
|
||||
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-users text-2xl opacity-20" />
|
||||
<div class="font-semibold text-[0.8rem]">Nenhuma associação</div>
|
||||
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.</div>
|
||||
@@ -254,7 +285,7 @@
|
||||
<!-- Dot cor -->
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
:style="g.cor ? colorStyle(g.cor) : { background: 'var(--surface-border)' }"
|
||||
:style="effectiveCor(g) ? colorStyle(effectiveCor(g)) : { background: 'var(--surface-border)' }"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">{{ g.nome }}</div>
|
||||
@@ -303,7 +334,7 @@
|
||||
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
|
||||
</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
|
||||
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
|
||||
{{ dlg.isSystem ? 'Grupo padrão — edição de cor' : dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,10 +355,14 @@
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon><i class="pi pi-sitemap" /></InputIcon>
|
||||
<InputText id="grp-nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
|
||||
<InputText id="grp-nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving || dlg.isSystem" @keydown.enter.prevent="saveDialog" />
|
||||
</IconField>
|
||||
<label for="grp-nome">Nome do grupo *</label>
|
||||
<label for="grp-nome">{{ dlg.isSystem ? 'Nome do grupo (padrão)' : 'Nome do grupo *' }}</label>
|
||||
</FloatLabel>
|
||||
<div v-if="dlg.isSystem" class="flex items-center gap-1.5 text-[0.72rem] text-[var(--text-color-secondary)] opacity-60 -mt-1">
|
||||
<i class="pi pi-info-circle text-[0.65rem]" />
|
||||
<span>Grupos padrão do sistema — apenas a cor pode ser alterada</span>
|
||||
</div>
|
||||
|
||||
<!-- Seletor de cor -->
|
||||
<div class="border border-[var(--surface-border,#e2e8f0)] rounded-md bg-[var(--surface-ground,#f8fafc)] p-3.5">
|
||||
@@ -379,7 +414,14 @@
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" :disabled="!String(dlg.nome || '').trim()" @click="saveDialog" />
|
||||
<Button
|
||||
:label="dlg.isSystem ? 'Salvar cor' : 'Salvar'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!dlg.isSystem && !String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -389,32 +431,50 @@
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
:header="patientsDialog.group?.nome ? `Pacientes — ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '860px', maxWidth: '95vw' }"
|
||||
:pt="{
|
||||
root: { style: `border: 4px solid ${patientsGroupHex}` },
|
||||
header: { style: `border-bottom: 1px solid ${patientsGroupHex}30` }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Info grupo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="patientsDialog.group?.cor" class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="colorStyle(patientsDialog.group.cor)" />
|
||||
<span class="text-[var(--text-color-secondary)] text-[1rem]">
|
||||
Grupo: <span class="font-semibold text-[var(--text-color)]">{{ patientsDialog.group?.nome || '—' }}</span>
|
||||
</span>
|
||||
<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="{ background: patientsGroupHex }"
|
||||
>
|
||||
{{ (patientsDialog.group?.nome || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[1rem] font-bold" :style="{ color: patientsGroupHex }">
|
||||
Grupo — {{ patientsDialog.group?.nome }}
|
||||
</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
{{ patientsDialog.items.length }} paciente{{ patientsDialog.items.length !== 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="patientsDialog.search" placeholder="Buscar paciente..." class="w-full" :disabled="patientsDialog.loading" />
|
||||
</IconField>
|
||||
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
|
||||
<span
|
||||
v-if="!patientsDialog.loading"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||
:style="{ background: `${patientsGroupHex}18`, color: patientsGroupHex }"
|
||||
>{{ patientsDialog.items.length }} paciente(s)</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-[var(--text-color-secondary)]">
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4" :style="{ color: patientsGroupHex }">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
@@ -423,7 +483,10 @@
|
||||
<div v-else>
|
||||
<!-- Empty -->
|
||||
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-indigo-500/10 text-indigo-500">
|
||||
<div
|
||||
class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md"
|
||||
:style="{ background: `${patientsGroupHex}18`, color: patientsGroupHex }"
|
||||
>
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente neste grupo</div>
|
||||
@@ -444,8 +507,17 @@
|
||||
<Column header="Paciente" sortable>
|
||||
<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="initials(data.full_name)" shape="circle" />
|
||||
<Avatar
|
||||
v-if="data.avatar_url"
|
||||
:image="data.avatar_url"
|
||||
shape="circle"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:label="initials(data.full_name)"
|
||||
shape="circle"
|
||||
:style="{ background: `${patientsGroupHex}25`, color: patientsGroupHex }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.full_name }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email || '—' }}</div>
|
||||
@@ -462,7 +534,14 @@
|
||||
|
||||
<Column header="Ação" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined @click="abrirPaciente(data)" />
|
||||
<Button
|
||||
label="Abrir"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
outlined
|
||||
:style="{ borderColor: patientsGroupHex, color: patientsGroupHex }"
|
||||
@click="abrirPaciente(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -479,7 +558,14 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="patientsDialog.open = false" />
|
||||
<Button
|
||||
label="Fechar"
|
||||
icon="pi pi-times"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:style="{ borderColor: patientsGroupHex, color: patientsGroupHex }"
|
||||
@click="patientsDialog.open = false"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
@@ -487,7 +573,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
@@ -503,6 +589,9 @@ import {
|
||||
deleteGroup
|
||||
} from '@/services/GruposPacientes.service.js'
|
||||
|
||||
import { getSysGroupColor, setSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js'
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
@@ -529,6 +618,7 @@ const grpMobileMenuItems = computed(() => [
|
||||
|
||||
const dt = ref(null)
|
||||
const loading = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const groups = ref([])
|
||||
const selectedGroups = ref([])
|
||||
|
||||
@@ -550,8 +640,22 @@ const quickStats = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
function systemDefaultColor (row) {
|
||||
return getSystemGroupDefaultColor(row.nome).replace('#', '')
|
||||
}
|
||||
|
||||
function effectiveCor (row) {
|
||||
if (row.is_system) {
|
||||
const stored = getSysGroupColor(row.id)
|
||||
if (stored) return stored.replace('#', '')
|
||||
if (row.cor) return row.cor
|
||||
return systemDefaultColor(row)
|
||||
}
|
||||
return row.cor || ''
|
||||
}
|
||||
|
||||
// ── Dialog ────────────────────────────────────────────────
|
||||
const dlg = reactive({ open: false, mode: 'create', id: '', nome: '', cor: '', saving: false })
|
||||
const dlg = reactive({ open: false, mode: 'create', id: '', nome: '', cor: '', saving: false, isSystem: false })
|
||||
|
||||
const dlgPresetColors = [
|
||||
{ bg: '6366f1', name: 'Índigo' },
|
||||
@@ -609,6 +713,12 @@ function colorStyle (cor) {
|
||||
return { background: hex }
|
||||
}
|
||||
|
||||
const patientsGroupHex = computed(() => {
|
||||
if (!patientsDialog.group) return '#6366f1'
|
||||
const raw = effectiveCor(patientsDialog.group)
|
||||
return raw ? (raw.startsWith('#') ? raw : `#${raw}`) : '#6366f1'
|
||||
})
|
||||
|
||||
function humanizeError (err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
|
||||
const code = err?.code
|
||||
@@ -628,6 +738,7 @@ async function fetchAll () {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
hasLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,18 +770,27 @@ function toggleRowSelection (row, checked) {
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────
|
||||
function openCreate () { dlg.open = true; dlg.mode = 'create'; dlg.id = ''; dlg.nome = ''; dlg.cor = '' }
|
||||
function openEdit (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = row.cor || '' }
|
||||
function openCreate () { dlg.open = true; dlg.mode = 'create'; dlg.id = ''; dlg.nome = ''; dlg.cor = ''; dlg.isSystem = false }
|
||||
function openEdit (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = row.cor || ''; dlg.isSystem = false }
|
||||
function openEditColor (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = effectiveCor(row); dlg.isSystem = true }
|
||||
|
||||
async function saveDialog () {
|
||||
const nome = String(dlg.nome || '').trim()
|
||||
if (!nome) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 }); return }
|
||||
if (nome.length < 2) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 }); return }
|
||||
if (!dlg.isSystem) {
|
||||
if (!nome) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 }); return }
|
||||
if (nome.length < 2) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 }); return }
|
||||
}
|
||||
const corRaw = String(dlg.cor || '').trim()
|
||||
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
|
||||
dlg.saving = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
if (dlg.isSystem) {
|
||||
setSysGroupColor(dlg.id, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Cor do grupo atualizada.', life: 2500 })
|
||||
dlg.open = false
|
||||
await fetchAll()
|
||||
return
|
||||
} else if (dlg.mode === 'create') {
|
||||
await createGroup(nome, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
|
||||
} else {
|
||||
@@ -760,7 +880,10 @@ async function openGroupPatientsModal (groupRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) { router.push(`/features/patients/cadastro/${patient.id}`) }
|
||||
const editPatientId = ref(null)
|
||||
const editPatientDialog = ref(false)
|
||||
function abrirPaciente (patient) { if (!patient?.id) return; editPatientId.value = String(patient.id); editPatientDialog.value = true }
|
||||
watch(editPatientDialog, (isOpen) => { if (!isOpen) editPatientId.value = null })
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/prontuario/PatientProntuario.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
@@ -26,7 +42,7 @@ const props = defineProps({
|
||||
patient: { type: Object, default: () => ({}) } // precisa ter id
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'edit'])
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -481,17 +497,8 @@ function close() {
|
||||
function editPatient() {
|
||||
const id = patientData.value?.id
|
||||
if (!id) return
|
||||
|
||||
// Detecta área pelo path atual — mesmo padrão do PatientsCadastroPage
|
||||
const isTherapist = String(route.path || '').startsWith('/therapist')
|
||||
|
||||
close()
|
||||
|
||||
if (isTherapist) {
|
||||
router.push({ name: 'therapist-patients-edit', params: { id } })
|
||||
} else {
|
||||
router.push({ name: 'admin-pacientes-cadastro-edit', params: { id } })
|
||||
}
|
||||
emit('edit', String(id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
@@ -578,7 +585,6 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
pt:title:class="text-[0.95rem] font-bold text-[var(--text-color)] truncate"
|
||||
@hide="close"
|
||||
>
|
||||
<Toast />
|
||||
|
||||
<!-- ── CONTEÚDO ── -->
|
||||
<div class="bg-[var(--surface-ground,#f5f7fa)]">
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/tags/TagsPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<PatientCadastroDialog
|
||||
v-model="editPatientDialog"
|
||||
:patient-id="editPatientId"
|
||||
/>
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
@@ -88,21 +108,26 @@
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
|
||||
}"
|
||||
>
|
||||
<template v-if="carregando">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
:class="s.cls === 'qs-ok' ? 'text-green-500' : 'text-[var(--text-color)]'"
|
||||
>{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
:class="s.cls === 'qs-ok' ? 'text-green-500' : 'text-[var(--text-color)]'"
|
||||
>{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
@@ -173,13 +198,16 @@
|
||||
|
||||
<Column header="Pacientes" sortable sortField="pacientes_count" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
class="p-0"
|
||||
link
|
||||
:label="String(data.pacientes_count ?? 0)"
|
||||
:disabled="Number(data.pacientes_count ?? 0) <= 0"
|
||||
<button
|
||||
v-if="Number(data.pacientes_count ?? 0) > 0"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold cursor-pointer border-0 transition-opacity hover:opacity-75"
|
||||
:style="{ background: `${data.cor ? (data.cor.startsWith('#') ? data.cor : '#' + data.cor) : '#6366f1'}18`, color: data.cor ? (data.cor.startsWith('#') ? data.cor : '#' + data.cor) : '#6366f1' }"
|
||||
@click="abrirModalPacientesDaTag(data)"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-users text-[0.6rem]" />
|
||||
{{ data.pacientes_count }}
|
||||
</button>
|
||||
<span v-else class="text-[var(--text-color-secondary)] opacity-40 text-xs">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -225,6 +253,7 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: tags mais usadas ─────────────── -->
|
||||
@@ -246,8 +275,13 @@
|
||||
>{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
<div v-if="carregando" class="flex flex-col gap-2 p-3">
|
||||
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-tags text-2xl opacity-20" />
|
||||
<div class="font-semibold text-[0.8rem]">Nenhuma tag em uso</div>
|
||||
<div class="text-[0.72rem] opacity-70 leading-relaxed">As tags mais usadas aparecem aqui quando houver pacientes associados.</div>
|
||||
@@ -280,11 +314,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge contagem -->
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] flex-shrink-0 bg-indigo-500/10 text-indigo-600">
|
||||
{{ Number(t.pacientes_count ?? 0) }}
|
||||
</span>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-[var(--primary-color,#6366f1)] transition-all duration-150 flex-shrink-0" />
|
||||
<!-- Badge contagem com cor da tag -->
|
||||
<span
|
||||
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] flex-shrink-0"
|
||||
:style="{ background: `${t.cor ? (t.cor.startsWith('#') ? t.cor : '#' + t.cor) : '#6366f1'}18`, color: t.cor ? (t.cor.startsWith('#') ? t.cor : '#' + t.cor) : '#6366f1' }"
|
||||
>{{ Number(t.pacientes_count ?? 0) }}</span>
|
||||
<i
|
||||
class="pi pi-chevron-right text-[0.6rem] opacity-30 group-hover:opacity-100 transition-all duration-150 flex-shrink-0"
|
||||
:style="{ color: 'var(--text-color-secondary)' }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -414,85 +452,149 @@
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="modalPacientes.open"
|
||||
:header="modalPacientesHeader"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '860px', maxWidth: '96vw' }"
|
||||
:pt="{
|
||||
root: { style: `border: 4px solid ${modalTagHex}` },
|
||||
header: { style: `border-bottom: 1px solid ${modalTagHex}30` }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Info da tag -->
|
||||
<div v-if="modalPacientes.tag" class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-[3px] h-4 rounded-sm flex-shrink-0"
|
||||
:style="{ background: modalPacientes.tag.cor || '#94a3b8' }"
|
||||
/>
|
||||
<span class="text-[var(--text-color-secondary)] text-[1rem]">
|
||||
Tag: <span class="font-semibold text-[var(--text-color)]">{{ modalPacientes.tag.nome }}</span>
|
||||
· <span class="text-[var(--primary-color,#6366f1)] font-semibold">{{ modalPacientes.items.length }} paciente(s)</span>
|
||||
</span>
|
||||
<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="{ background: modalTagHex }"
|
||||
>
|
||||
{{ (modalPacientes.tag?.nome || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[1rem] font-bold" :style="{ color: modalTagHex }">
|
||||
Tag — {{ modalPacientes.tag?.nome }}
|
||||
</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
{{ modalPacientes.items.length }} paciente{{ modalPacientes.items.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Busca + reload -->
|
||||
<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="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" />
|
||||
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" :disabled="modalPacientes.loading" />
|
||||
</IconField>
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" @click="recarregarModalPacientes" />
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||
:style="{ background: `${modalTagHex}18`, color: modalTagHex }"
|
||||
>{{ modalPacientes.items.length }} paciente(s)</span>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
outlined
|
||||
class="h-8 w-8 rounded-full flex-shrink-0"
|
||||
:style="{ borderColor: modalTagHex, color: modalTagHex }"
|
||||
@click="recarregarModalPacientes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="modalPacientes.error" severity="error">{{ modalPacientes.error }}</Message>
|
||||
<!-- Loading -->
|
||||
<div v-if="modalPacientes.loading" class="flex items-center gap-2 py-4" :style="{ color: modalTagHex }">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<DataTable
|
||||
:value="modalPacientesFiltrado"
|
||||
:loading="modalPacientes.loading"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="8"
|
||||
:rowsPerPageOptions="[8, 15, 30]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="name" header="Paciente" sortable>
|
||||
<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 icon="pi pi-user" shape="circle" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.name }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">{{ data.email || '—' }}</div>
|
||||
<Message v-else-if="modalPacientes.error" severity="error">{{ modalPacientes.error }}</Message>
|
||||
|
||||
<div v-else>
|
||||
<!-- Empty -->
|
||||
<div v-if="modalPacientes.items.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: `${modalTagHex}18`, color: modalTagHex }"
|
||||
>
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente nesta tag</div>
|
||||
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">Associe pacientes a esta tag na página de pacientes.</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<DataTable
|
||||
v-else
|
||||
:value="modalPacientesFiltrado"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="8"
|
||||
:rowsPerPageOptions="[8, 15, 30]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="name" header="Paciente" sortable>
|
||||
<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.name || '?')[0].toUpperCase()"
|
||||
shape="circle"
|
||||
:style="{ background: `${modalTagHex}25`, color: modalTagHex }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.name }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">{{ data.email || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="width: 13rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneBR(data.phone) }}</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: modalTagHex, color: modalTagHex }"
|
||||
@click="abrirPaciente(data)"
|
||||
/>
|
||||
</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-[1rem]">Nenhum resultado</div>
|
||||
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde à busca.</div>
|
||||
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="modalPacientes.search = ''" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="width: 13rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneBR(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined @click="abrirPaciente(data)" />
|
||||
</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-[1rem]">Nenhum resultado</div>
|
||||
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde a "{{ modalPacientes.search }}".</div>
|
||||
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="modalPacientes.search = ''" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="modalPacientes.open = false" />
|
||||
<Button
|
||||
label="Fechar"
|
||||
icon="pi pi-times"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:style="{ borderColor: modalTagHex, color: modalTagHex }"
|
||||
@click="modalPacientes.open = false"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
@@ -500,7 +602,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
@@ -509,6 +611,7 @@ import Checkbox from 'primevue/checkbox'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -537,6 +640,7 @@ const mobileMenuItems = computed(() => [
|
||||
|
||||
const dt = ref(null)
|
||||
const carregando = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const etiquetas = ref([])
|
||||
const etiquetasSelecionadas = ref([])
|
||||
|
||||
@@ -591,9 +695,11 @@ const isCustomColor = computed(() => {
|
||||
// ── Modal pacientes ───────────────────────────────────────
|
||||
const modalPacientes = reactive({ open: false, loading: false, error: '', tag: null, items: [], search: '' })
|
||||
|
||||
const modalPacientesHeader = computed(() =>
|
||||
modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'
|
||||
)
|
||||
const modalTagHex = computed(() => {
|
||||
const cor = modalPacientes.tag?.cor
|
||||
if (!cor) return '#6366f1'
|
||||
return cor.startsWith('#') ? cor : `#${cor}`
|
||||
})
|
||||
|
||||
const modalPacientesFiltrado = computed(() => {
|
||||
const s = String(modalPacientes.search || '').trim().toLowerCase()
|
||||
@@ -712,6 +818,7 @@ async function buscarEtiquetas () {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar tags', detail: e?.message || 'Verifique se as tabelas/views existem.', life: 6000 })
|
||||
} finally {
|
||||
carregando.value = false
|
||||
hasLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,7 +966,10 @@ function fmtPhoneBR (v) {
|
||||
return d
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) { router.push(`/features/patients/cadastro/${patient.id}`) }
|
||||
const editPatientId = ref(null)
|
||||
const editPatientDialog = ref(false)
|
||||
function abrirPaciente (patient) { if (!patient?.id) return; editPatientId.value = String(patient.id); editPatientDialog.value = true }
|
||||
watch(editPatientDialog, (isOpen) => { if (!isOpen) editPatientId.value = null })
|
||||
|
||||
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
|
||||
function isRecent (row) {
|
||||
|
||||
Reference in New Issue
Block a user