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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user