carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline

This commit is contained in:
Leonardo
2026-03-18 09:26:09 -03:00
parent 66f67cd40f
commit d6d2fe29d1
55 changed files with 3655 additions and 1512 deletions

View File

@@ -157,6 +157,18 @@
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</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"
@@ -436,7 +448,7 @@
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
</template>
</Column>
@@ -481,13 +493,17 @@
</template>
</Column>
<Column :key="'col-acoes'" header="Ações" style="width: 20rem;" frozen alignFrozen="right">
<Column :key="'col-acoes'" header="Ações" style="width: 22rem;" frozen alignFrozen="right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
<Button v-if="historySet.has(data.id)" :label="`Sessões × ${sessionCountMap.get(data.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<PatientActionMenu
:patient="data"
:hasHistory="historySet.has(data.id)"
@updated="fetchAll"
/>
</div>
</template>
</Column>
@@ -542,7 +558,7 @@
</div>
<div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div>
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
<Tag :value="pat.status" :severity="statusSeverity(pat.status)" />
</div>
<!-- Grupos + Tags -->
@@ -553,10 +569,14 @@
<!-- Ações -->
<div class="mt-3 flex gap-2 justify-end flex-wrap">
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
<Button v-if="historySet.has(pat.id)" :label="`Sessões × ${sessionCountMap.get(pat.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
<PatientActionMenu
:patient="pat"
:hasHistory="historySet.has(pat.id)"
@updated="fetchAll"
/>
</div>
</div>
</div>
@@ -586,14 +606,85 @@
</TabPanel>
<TabPanel value="grupos">
<Card>
<template #content>
<div class="flex items-center justify-between gap-3">
<div class="text-color-secondary">Atalho para a página de Grupos.</div>
<Button label="Abrir Grupos" icon="pi pi-external-link" outlined @click="goGroups" />
<!-- Cabeçalho da view de grupos -->
<div class="flex items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-2">
<i class="pi pi-sitemap text-[var(--primary-color,#6366f1)]" />
<span class="font-semibold text-[var(--text-color)]">Pacientes distribuídos por grupo</span>
<span
v-if="groupedPatientsView.length"
class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold"
>{{ groupedPatientsView.length }}</span>
</div>
<Button label="Gerenciar grupos" icon="pi pi-external-link" severity="secondary" outlined size="small" @click="goGroups" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<!-- Empty -->
<div v-else-if="groupedPatientsView.length === 0" class="flex flex-col items-center justify-center gap-3 py-12 text-[var(--text-color-secondary)]">
<div class="w-14 h-14 rounded-xl bg-indigo-500/10 flex items-center justify-center">
<i class="pi pi-sitemap text-2xl text-indigo-500" />
</div>
<div class="font-semibold text-[var(--text-color)]">Nenhuma associação encontrada</div>
<div class="text-sm opacity-70 text-center max-w-xs">Associe pacientes a grupos no cadastro ou na listagem para visualizá-los aqui.</div>
<Button label="Gerenciar grupos" icon="pi pi-sitemap" outlined size="small" class="mt-1" @click="goGroups" />
</div>
<!-- Grid de grupos -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="grp in groupedPatientsView"
:key="grp.id"
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)" />
<!-- Header do grupo -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)]">
<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)"
>
{{ (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="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }}
</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)"
>{{ grp.patients.length }}</span>
</div>
</template>
</Card>
<!-- 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"
v-tooltip.top="p.nome_completo"
@click="goEdit(p)"
>
<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">
{{ (p.nome_completo || '?').charAt(0).toUpperCase() }}
</span>
<span class="max-w-[120px] truncate">{{ (p.nome_completo || '—').split(' ').slice(0, 2).join(' ') }}</span>
</button>
<span
v-if="grp.patients.length > 12"
class="inline-flex items-center px-2.5 py-1 rounded-full border border-dashed border-[var(--surface-border)] text-xs text-[var(--text-color-secondary)] font-medium"
>+{{ grp.patients.length - 12 }} mais</span>
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
@@ -696,6 +787,7 @@ 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'
// ── Descontos por paciente ────────────────────────────────────────
const discountMap = ref({})
@@ -827,20 +919,24 @@ function setAllColumns () { selectedColumns.value = columnCatalogAll.map(c =
const sort = reactive({ field: 'created_at', order: -1 })
const kpis = reactive({ total: 0, active: 0, inactive: 0, latestLastAttended: '' })
const kpis = reactive({ total: 0, active: 0, inactive: 0, archived: 0, latestLastAttended: '' })
const filters = reactive({
status: 'Todos', search: '',
status: 'Ativo', search: '',
groupId: null, tagId: null,
createdFrom: null, createdTo: null
})
const statusOptions = [
{ label: 'Todos', value: 'Todos' },
{ label: 'Ativo', value: 'Ativo' },
{ label: 'Inativo', value: 'Inativo' }
{ label: 'Ativos', value: 'Ativo' },
{ label: 'Inativos', value: 'Inativo' },
{ label: 'Arquivados', value: 'Arquivado' },
{ label: 'Todos', value: 'Todos' }
]
const historySet = ref(new Set())
const sessionCountMap = ref(new Map())
const groupOptions = computed(() => (groups.value || []).map(g => ({ label: g.name, value: g.id })))
const tagOptions = computed(() => (tags.value || []).map(t => ({ label: t.name, value: t.id })))
@@ -964,7 +1060,7 @@ function onFilterChangedDebounced () {
function onFilterChanged () { updateKpis() }
function setStatus (s) { filters.status = s; onFilterChanged() }
function clearAllFilters () {
filters.status = 'Todos'; filters.search = ''; filters.groupId = null
filters.status = 'Ativo'; filters.search = ''; filters.groupId = null
filters.tagId = null; filters.createdFrom = null; filters.createdTo = null
onFilterChanged()
}
@@ -987,6 +1083,15 @@ function normalizeStatus (s) {
return v.charAt(0).toUpperCase() + v.slice(1)
}
function statusSeverity (s) {
if (s === 'Ativo') return 'success'
if (s === 'Inativo') return 'warn'
if (s === 'Arquivado') return 'secondary'
if (s === 'Alta') return 'info'
if (s === 'Encaminhado') return 'contrast'
return 'secondary'
}
function initials (name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
@@ -1196,44 +1301,68 @@ async function hydrateAssociationsSupabase () {
groups: groupsByPatient.get(p.id) || [],
tags: tagsByPatient.get(p.id) || []
}))
}
// ── Delete ────────────────────────────────────────────────
function confirmDeleteOne (row) {
const nome = row?.nome_completo || 'este paciente'
confirm.require({
header: 'Excluir paciente',
message: `Tem certeza que deseja excluir "${nome}"?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger',
accept: () => removePatient(row)
})
}
// Calcula historySet — uma única query para todos os ids
const { data: evtCounts } = await supabase
.from('agenda_eventos')
.select('patient_id')
.in('patient_id', ids)
.not('patient_id', 'is', null)
.limit(1000)
async function removePatient (row) {
try {
await supabase.from('patient_group_patient').delete().eq('patient_id', row.id)
await supabase.from('patient_patient_tag').delete().eq('patient_id', row.id)
const { error } = await supabase.from('patients').delete().eq('id', row.id).eq('owner_id', uid.value)
if (error) throw error
patients.value = (patients.value || []).filter(p => p.id !== row.id)
updateKpis()
toast.add({ severity: 'success', summary: 'Ok', detail: 'Paciente excluído.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui excluir.', life: 3500 })
const tempSet = new Set()
const countMap = new Map()
for (const r of (evtCounts || [])) {
if (r.patient_id) {
tempSet.add(r.patient_id)
countMap.set(r.patient_id, (countMap.get(r.patient_id) || 0) + 1)
}
}
historySet.value = tempSet
sessionCountMap.value = countMap
}
// Delete movido para PatientActionMenu + usePatientLifecycle
// ── KPIs ──────────────────────────────────────────────────
function updateKpis () {
const all = patients.value || []
kpis.total = all.length
kpis.active = all.filter(p => p.status === 'Ativo').length
kpis.inactive = all.filter(p => p.status === 'Inativo').length
kpis.archived = all.filter(p => p.status === 'Arquivado').length
const dates = all.map(p => (p.last_attended_at || '').slice(0, 10)).filter(Boolean).sort()
kpis.latestLastAttended = dates.length ? dates[dates.length - 1] : ''
}
// ── Grupos view ───────────────────────────────────────────
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 ungrouped = { id: '__none__', name: 'Sem grupo', color: null, patients: [], isSystem: false }
for (const p of all) {
const gs = p.groups || []
if (!gs.length) {
ungrouped.patients.push(p)
} else {
for (const g of gs) {
if (grpMap.has(g.id)) grpMap.get(g.id).patients.push(p)
}
}
}
const result = [...grpMap.values()].filter(g => g.patients.length > 0).sort((a, b) => b.patients.length - a.patients.length)
if (ungrouped.patients.length > 0) result.push(ungrouped)
return result
})
function grpColorStyle (color) {
if (!color) return { background: 'var(--surface-border)' }
return { background: color.startsWith('#') ? color : `#${color}` }
}
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) {
if (!row?.created_at) return false