Files
agenciapsilmno/src/features/patients/PatientsListPage.vue

1458 lines
74 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| 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
|--------------------------------------------------------------------------
-->
<script setup>
import { supabase } from '@/lib/supabase/client';
import Menu from 'primevue/menu';
import MultiSelect from 'primevue/multiselect';
import Popover from 'primevue/popover';
import ProgressSpinner from 'primevue/progressspinner';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
import { getSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js';
// ── Descontos por paciente ────────────────────────────────────────
const discountMap = ref({});
// ── Convênio por paciente ─────────────────────────────────────────
const insuranceMap = ref({});
// ── Sessões do paciente ──────────────────────────────────────────
const sessoesOpen = ref(false);
const sessoesPaciente = ref(null);
const sessoesLoading = ref(false);
const sessoesLista = ref([]);
const recorrencias = ref([]);
const MESES_BR = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
function fmtDataSessao(iso) {
if (!iso) return '—';
const d = new Date(iso);
return `${String(d.getDate()).padStart(2, '0')} ${MESES_BR[d.getMonth()]} ${d.getFullYear()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function statusSessaoSev(st) {
return { agendado: 'info', realizado: 'success', cancelado: 'danger', faltou: 'warn' }[st] || 'secondary';
}
async function abrirSessoes(pat) {
sessoesPaciente.value = pat;
sessoesOpen.value = true;
sessoesLoading.value = true;
sessoesLista.value = [];
recorrencias.value = [];
try {
const [evts, recs] = await Promise.all([
supabase
.from('agenda_eventos')
.select('id, titulo, tipo, status, inicio_em, fim_em, modalidade, insurance_guide_number, insurance_value, insurance_plans(name)')
.eq('patient_id', pat.id)
.order('inicio_em', { ascending: false })
.limit(100),
supabase.from('recurrence_rules').select('id, type, interval, weekdays, start_date, end_date, start_time, duration_min, status').eq('patient_id', pat.id).order('start_date', { ascending: false })
]);
sessoesLista.value = evts.data || [];
recorrencias.value = recs.data || [];
} catch (e) {
console.error('Erro ao carregar sessões:', e);
} finally {
sessoesLoading.value = false;
}
}
const DIAS_SEMANA = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
function fmtRecorrencia(r) {
const dias = (r.weekdays || []).map((d) => DIAS_SEMANA[d]).join(', ');
const freq = r.type === 'weekly' && r.interval === 2 ? 'Quinzenal' : r.type === 'weekly' ? 'Semanal' : 'Personalizado';
return `${freq} · ${dias} · ${r.start_time?.slice(0, 5) || '—'} · ${r.duration_min || 50}min`;
}
const router = useRouter();
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
// ── Mobile menu ───────────────────────────────────────────
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 },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
];
const uid = ref(null);
const loading = ref(false);
const hasLoaded = ref(false);
const showAdvanced = 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);
const patients = ref([]);
const groups = ref([]);
const tags = ref([]);
// ── Colunas ───────────────────────────────────────────────
const colPopover = ref(null);
const columnCatalogAll = [
{ key: 'paciente', label: 'Paciente', locked: true },
{ key: 'status', label: 'Status', locked: true },
{ key: 'telefone', label: 'Telefone', locked: true },
{ key: 'email', label: 'Email' },
{ key: 'last_attended_at', label: 'Último Atendimento' },
{ key: 'created_at', label: 'Cadastrado em' },
{ key: 'grupos', label: 'Grupos' },
{ key: 'tags', label: 'Tags' },
{ key: 'acoes', label: 'Ações', locked: true }
];
const selectedColumns = ref(['paciente', 'status', 'telefone', 'acoes']);
const lockedKeys = computed(() => columnCatalogAll.filter((c) => c.locked).map((c) => c.key));
const visibleKeys = computed(() => {
const set = new Set(selectedColumns.value || []);
lockedKeys.value.forEach((k) => set.add(k));
return Array.from(set);
});
function isColVisible(key) {
return visibleKeys.value.includes(key);
}
function toggleColumnsPopover(event) {
colPopover.value?.toggle(event);
}
function setDefaultColumns() {
selectedColumns.value = ['paciente', 'status', 'telefone', 'acoes'];
}
function setAllColumns() {
selectedColumns.value = columnCatalogAll.map((c) => c.key);
}
const sort = reactive({ field: 'created_at', order: -1 });
const kpis = reactive({ total: 0, active: 0, inactive: 0, archived: 0, latestLastAttended: '' });
const filters = reactive({
status: 'Ativo',
search: '',
groupId: null,
tagId: null,
createdFrom: null,
createdTo: null
});
const statusOptions = [
{ 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 })));
const hasActiveFilters = computed(() => Boolean(String(filters.search || '').trim() || (filters.status && filters.status !== 'Todos') || filters.groupId || filters.tagId || filters.createdFrom || filters.createdTo));
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
_observer = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0, rootMargin }
);
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
await loadUser();
await fetchAll();
});
onBeforeUnmount(() => {
_observer?.disconnect();
});
function fmtBRL(v) {
if (v == null || v === '') return '—';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDiscount(d) {
if (!d) return null;
const parts = [];
if (Number(d.discount_pct) > 0) parts.push(`${Number(d.discount_pct)}%`);
if (Number(d.discount_flat) > 0) parts.push(fmtBRL(d.discount_flat));
return parts.length ? parts.join(' + ') : null;
}
function fmtPhoneBR(v) {
const d = String(v ?? '').replace(/\D/g, '');
if (!d) return '—';
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
return d;
}
// ── UI actions ────────────────────────────────────────────
function openProntuario(row) {
if (!row?.id) return;
selectedPatient.value = { ...row };
prontuarioOpen.value = true;
}
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 = [
{
...row,
status: normalizeStatus(row.status),
created_at: (row.created_at || '').slice(0, 10),
updated_at: (row.updated_at || '').slice(0, 10),
last_attended_at: (row.last_attended_at || '').slice(0, 10),
groups: row.groups || [],
tags: row.tags || []
},
...(patients.value || [])
];
sort.field = 'created_at';
sort.order = -1;
updateKpis();
}
// ── Navigation ────────────────────────────────────────────
function getAreaBase() {
const seg = String(route.path || '').split('/')[1];
return seg === 'therapist' ? '/therapist' : '/admin';
}
function getAreaKey() {
const seg = String(route.path || '').split('/')[1];
return seg === 'therapist' ? 'therapist' : 'admin';
}
function getPatientsRoutes() {
const area = getAreaKey();
if (area === 'therapist')
return {
groupsPath: '/therapist/patients/grupos',
createPath: '/therapist/patients/cadastro',
editPath: (id) => `/therapist/patients/cadastro/${id}`,
createName: 'therapist-patients-cadastro',
editName: 'therapist-patients-cadastro-edit',
groupsName: 'therapist-patients-grupos'
};
return {
groupsPath: '/admin/pacientes/grupos',
createPath: '/admin/pacientes/cadastro',
editPath: (id) => `/admin/pacientes/cadastro/${id}`,
createName: 'admin-pacientes-cadastro',
editName: 'admin-pacientes-cadastro-edit',
groupsName: 'admin-pacientes-grupos'
};
}
function safePush(toObj, fallbackPath) {
try {
const r = router.resolve(toObj);
if (r?.matched?.length) return router.push(toObj);
} catch (_) {}
return router.push(fallbackPath);
}
function goGroups() {
const r = getPatientsRoutes();
return safePush({ name: r.groupsName }, r.groupsPath);
}
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;
function onFilterChangedDebounced() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => onFilterChanged(), 250);
}
function onFilterChanged() {
updateKpis();
}
function setStatus(s) {
filters.status = s;
onFilterChanged();
}
function clearAllFilters() {
filters.status = 'Ativo';
filters.search = '';
filters.groupId = null;
filters.tagId = null;
filters.createdFrom = null;
filters.createdTo = null;
onFilterChanged();
}
function onSort(e) {
sort.field = e.sortField;
sort.order = e.sortOrder;
}
// ── Helpers ───────────────────────────────────────────────
function prettyDate(iso) {
if (!iso) return '—';
const parts = String(iso).slice(0, 10).split('-');
if (parts.length !== 3) return String(iso);
const [yyyy, mm, dd] = parts;
return `${dd}/${mm}/${yyyy}`;
}
function normalizeStatus(s) {
const v = String(s || '')
.toLowerCase()
.trim();
if (!v) return 'Ativo';
if (v === 'active' || v === 'ativo') return 'Ativo';
if (v === 'inactive' || v === 'inativo') return 'Inativo';
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 '—';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function shortId(id) {
const s = String(id || '');
return s.length > 8 ? s.slice(0, 8) : s;
}
function chipStyle(color) {
if (!color) return { background: 'rgba(148,163,184,.18)', border: '1px solid rgba(148,163,184,.30)', color: 'var(--text-color)' };
return { background: hexToRgba(color, 0.16), border: `1px solid ${hexToRgba(color, 0.3)}`, color: 'var(--text-color)' };
}
function hexToRgba(hex, alpha) {
const h = String(hex || '')
.replace('#', '')
.trim();
if (![3, 6].includes(h.length)) return `rgba(255,255,255,${alpha})`;
const full =
h.length === 3
? h
.split('')
.map((ch) => ch + ch)
.join('')
: h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// ── Auth ──────────────────────────────────────────────────
async function loadUser() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
uid.value = data?.user?.id || null;
}
function withOwnerFilter(q) {
return uid.value ? q.eq('owner_id', uid.value) : q;
}
// ── Filtered rows ─────────────────────────────────────────
const filteredRows = computed(() => {
const s = String(filters.search || '')
.trim()
.toLowerCase();
const toISO = (d) => {
if (!d) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const from = toISO(filters.createdFrom);
const to = toISO(filters.createdTo);
const inDateRange = (row) => {
if (!from && !to) return true;
const rowDate = (row.created_at || '').slice(0, 10);
if (!rowDate) return true;
if (from && rowDate < from) return false;
if (to && rowDate > to) return false;
return true;
};
let rows = (patients.value || []).filter((r) => {
if (filters.status !== 'Todos' && r.status !== filters.status) return false;
if (s) {
const hay = `${r.nome_completo || ''} ${r.email_principal || ''} ${r.telefone || ''}`.toLowerCase();
if (!hay.includes(s)) return false;
}
if (!inDateRange(r)) return false;
if (filters.groupId && !(r.groups || []).some((g) => g.id === filters.groupId)) return false;
if (filters.tagId && !(r.tags || []).some((t) => t.id === filters.tagId)) return false;
return true;
});
if (sort.field) {
rows = rows.slice().sort((a, b) => {
const av = a[sort.field] ?? '',
bv = b[sort.field] ?? '';
if (av === bv) return 0;
return av > bv ? sort.order : -sort.order;
});
}
return rows;
});
// ── Fetch ─────────────────────────────────────────────────
async function fetchAll() {
loading.value = true;
try {
patients.value = await listPatients();
discountMap.value = {};
if (uid.value) {
const now = new Date().toISOString();
const { data: discRows } = await supabase.from('patient_discounts').select('patient_id, discount_pct, discount_flat').eq('owner_id', uid.value).eq('active', true).or(`active_to.is.null,active_to.gte.${now}`);
if (discRows) discountMap.value = Object.fromEntries(discRows.map((d) => [d.patient_id, d]));
}
insuranceMap.value = {};
if (uid.value) {
const { data: insRows } = await supabase.from('agenda_eventos').select('patient_id, insurance_plan_id, insurance_plans(name)').eq('owner_id', uid.value).not('insurance_plan_id', 'is', null).order('inicio_em', { ascending: false });
if (insRows) {
for (const row of insRows) {
if (!insuranceMap.value[row.patient_id]) insuranceMap.value[row.patient_id] = row.insurance_plans?.name ?? null;
}
}
}
groups.value = await listGroups();
tags.value = await listTags();
await hydrateAssociationsSupabase();
updateKpis();
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui carregar pacientes.', life: 3500 });
} finally {
loading.value = false;
hasLoaded.value = true;
}
}
async function listPatients() {
let q = supabase.from('patients').select('id, owner_id, nome_completo, email_principal, telefone, avatar_url, status, last_attended_at, created_at, updated_at').order('created_at', { ascending: false });
q = withOwnerFilter(q);
const { data, error } = await q;
if (error) throw error;
return (data || []).map((p) => ({
...p,
status: normalizeStatus(p.status),
last_attended_at: (p.last_attended_at || '').slice(0, 10),
created_at: (p.created_at || '').slice(0, 10),
updated_at: (p.updated_at || '').slice(0, 10),
groups: [],
tags: []
}));
}
async function listGroups() {
let q = supabase.from('patient_groups').select('id, owner_id, nome, cor, is_system, is_active').eq('is_active', true).order('nome', { ascending: true });
if (uid.value) q = q.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
else q = q.eq('is_system', true);
const { data, error } = await q;
if (error) throw error;
return (data || []).map((g) => ({ ...g, name: g.nome, color: g.cor }));
}
async function listTags() {
let q = supabase.from('patient_tags').select('id, owner_id, nome, cor').order('nome', { ascending: true });
if (uid.value) q = q.eq('owner_id', uid.value);
const { data, error } = await q;
if (error) throw error;
return (data || []).map((t) => ({ ...t, name: t.nome, color: t.cor }));
}
async function hydrateAssociationsSupabase() {
const ids = (patients.value || []).map((p) => p.id).filter(Boolean);
if (!ids.length) return;
const { data: pg, error: pgErr } = await supabase.from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', ids);
if (pgErr) throw pgErr;
const groupIds = Array.from(new Set((pg || []).map((r) => r.patient_group_id).filter(Boolean)));
let groupCatalog = [];
if (groupIds.length) {
let gq = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').in('id', groupIds).eq('is_active', true);
if (uid.value) gq = gq.or(`is_system.eq.true,owner_id.eq.${uid.value}`);
else gq = gq.eq('is_system', true);
const { data: gcatData, error: gErr } = await gq;
if (gErr) throw gErr;
groupCatalog = (gcatData || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor }));
}
const gById = new Map(groupCatalog.map((g) => [g.id, g]));
const groupsByPatient = new Map();
for (const rel of pg || []) {
const arr = groupsByPatient.get(rel.patient_id) || [];
const g = gById.get(rel.patient_group_id);
if (g) arr.push(g);
groupsByPatient.set(rel.patient_id, arr);
}
const { data: pt, error: ptErr } = await supabase.from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', ids);
if (ptErr) throw ptErr;
const tagIds = Array.from(new Set((pt || []).map((r) => r.tag_id).filter(Boolean)));
let tagCatalog = [];
if (tagIds.length) {
let tq = supabase.from('patient_tags').select('id, nome, cor').in('id', tagIds);
tq = withOwnerFilter(tq);
const { data: tcatData, error: tErr } = await tq;
if (tErr) throw tErr;
tagCatalog = (tcatData || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
const tById = new Map(tagCatalog.map((t) => [t.id, t]));
const tagsByPatient = new Map();
for (const rel of pt || []) {
const arr = tagsByPatient.get(rel.patient_id) || [];
const t = tById.get(rel.tag_id);
if (t) arr.push(t);
tagsByPatient.set(rel.patient_id, arr);
}
patients.value = (patients.value || []).map((p) => ({
...p,
groups: groupsByPatient.get(p.id) || [],
tags: tagsByPatient.get(p.id) || []
}));
// 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);
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 || []) {
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) {
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 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;
function isRecent(row) {
if (!row?.created_at) return false;
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS;
}
</script>
<template>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
Hero sticky
-->
<div
ref="headerEl"
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"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-400/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-users text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Pacientes</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Gerencie seus pacientes, histórico e status de atendimento</div>
</div>
</div>
<!-- Busca (desktop) o campo de busca, sem "Mais filtros" e sem "Colunas" -->
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-2">
<div class="flex-1 max-w-xs">
<FloatLabel variant="on">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText id="patSearch" v-model="filters.search" class="w-full" variant="filled" @input="onFilterChangedDebounced" />
</IconField>
<label for="patSearch">Buscar por nome, e-mail ou telefone...</label>
</FloatLabel>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" />
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" @go-complete="goCreateFull" />
<PatientCadastroDialog v-model="cadastroFullDialog" :patient-id="editPatientId" @created="onPatientCreated" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 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="(e) => createPopoverRef?.toggle(e)" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
<Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
</div>
</div>
</div>
<!--
Stats row
Mobile (< xl / 1280px):
Linha 1 Agenda + Descontos (flex, 2 cols)
Linha 2 Total + Ativos + Inativos + Último atend.
Desktop (xl+): tudo em uma única linha
-->
<div class="px-3 md:px-4 mb-3 flex flex-col xl:flex-row gap-2">
<!-- Linha 1 (mobile) / parte esquerda (desktop): atalhos -->
<div class="flex gap-2">
<!-- Minha Agenda -->
<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)] 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)]"
@click="router.push(getAreaBase() + '/agenda')"
>
<div class="flex items-center gap-1.5">
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
<span class="text-[1.1rem] font-bold leading-none text-[var(--primary-color,#6366f1)]">Agenda</span>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ver meus compromissos</div>
</div>
<!-- Descontos -->
<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)] flex-1 cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-amber-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
@click="router.push('/configuracoes/descontos')"
>
<div class="flex items-center gap-1.5">
<i class="pi pi-percentage text-amber-500" />
<span class="text-[1.1rem] font-bold leading-none text-amber-500">Descontos</span>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ver descontos aplicados</div>
</div>
</div>
<!-- Linha 2 (mobile) / parte direita (desktop): KPIs -->
<div class="flex gap-2 flex-1 flex-wrap xl:flex-nowrap">
<template v-if="loading">
<Skeleton v-for="n in 5" :key="n" height="3.5rem" class="flex-1 min-w-[72px] rounded-md" />
</template>
<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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- Chips de filtros ativos -->
<div v-if="hasActiveFilters" class="mx-3 md:mx-4 mb-3 flex flex-wrap items-center gap-2">
<span class="text-base text-color-secondary">Filtros ativos:</span>
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
</div>
<!-- Dialog busca mobile -->
<Dialog v-model:visible="searchMobileDlg" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Buscar paciente" class="w-[94vw] max-w-sm">
<div class="pt-1">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="filters.search" class="w-full" placeholder="Nome, telefone..." autofocus @input="onFilterChangedDebounced" />
</IconField>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchMobileDlg = false" />
</template>
</Dialog>
<!-- TABS -->
<Tabs value="pacientes" class="px-3 md:px-4 mb-5">
<TabList>
<Tab value="pacientes"><i class="pi pi-users mr-2" />Pacientes</Tab>
<Tab value="espera"><i class="pi pi-hourglass mr-2" />Lista de espera</Tab>
<Tab value="grupos"><i class="pi pi-sitemap mr-2" />Grupos</Tab>
</TabList>
<TabPanels>
<TabPanel value="pacientes">
<!-- Filters -->
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row md:items-end md:flex-wrap gap-3">
<div class="w-full lg:flex-1">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters.search" class="w-full" placeholder="Procurar..." @input="onFilterChangedDebounced" variant="filled" />
</IconField>
</FloatLabel>
</div>
<div class="flex gap-2 justify-end">
<Button label="Mais filtros" icon="pi pi-sliders-h" severity="secondary" outlined @click="showAdvanced = !showAdvanced" />
<!-- Popover colunas -->
<Button icon="pi pi-table" severity="secondary" outlined v-tooltip.top="'Colunas'" @click="toggleColumnsPopover($event)" />
<Button label="Limpar filtros" icon="pi pi-filter-slash" severity="danger" outlined :disabled="!hasActiveFilters" @click="clearAllFilters" />
</div>
<Popover ref="colPopover">
<div class="p-3" style="min-width: 280px">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="text-base font-semibold">Colunas</div>
<div class="flex gap-2">
<Button label="Padrão" size="small" severity="secondary" text @click="setDefaultColumns" />
<Button label="Todas" size="small" severity="secondary" text @click="setAllColumns" />
</div>
</div>
<MultiSelect v-model="selectedColumns" :options="columnCatalogAll" optionLabel="label" optionValue="key" :optionDisabled="(opt) => !!opt.locked" class="w-full" placeholder="Selecione colunas">
<template #value="{ value }">
<span class="text-base text-color-secondary">
<template v-if="(value?.length || 0) === 4">Padrão (4 colunas)</template>
<template v-else>{{ value?.length || 0 }} colunas selecionadas</template>
</span>
</template>
<template #option="{ option }">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span class="font-medium">{{ option.label }}</span>
<span v-if="option.locked" class="text-base text-color-secondary">(fixa)</span>
</div>
<i v-if="option.locked" class="pi pi-lock text-base opacity-70" />
</div>
</template>
</MultiSelect>
<div class="mt-3 text-base text-color-secondary">Paciente, Status, Telefone e Ações ficam sempre visíveis.</div>
</div>
</Popover>
</div>
<Transition name="fade">
<div v-if="showAdvanced" class="grid gap-3 mb-4 p-4 bg-gray-100 rounded-md items-end grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<div class="min-w-0">
<label class="block mb-2">Status</label>
<Select v-model="filters.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="(todos)" class="w-full" @change="onFilterChanged" />
</div>
<div class="min-w-0">
<label class="block mb-2">Grupo</label>
<Select v-model="filters.groupId" :options="groupOptions" optionLabel="label" optionValue="value" placeholder="(todos)" showClear class="w-full" @change="onFilterChanged" />
</div>
<div class="min-w-0">
<label class="block mb-2">Tag</label>
<Select v-model="filters.tagId" :options="tagOptions" optionLabel="label" optionValue="value" placeholder="(todas)" showClear class="w-full" @change="onFilterChanged" />
</div>
<div class="min-w-0">
<label class="block mb-2">Cadastrado (de)</label>
<DatePicker v-model="filters.createdFrom" dateFormat="dd-mm-yy" placeholder="DD-MM-AAAA" showIcon class="w-full" @update:modelValue="onFilterChanged" />
</div>
<div class="min-w-0">
<label class="block mb-2">Cadastrado (até)</label>
<DatePicker v-model="filters.createdTo" dateFormat="dd-mm-yy" placeholder="DD-MM-AAAA" showIcon class="w-full" @update:modelValue="onFilterChanged" />
</div>
</div>
</Transition>
</div>
<!-- Table desktop (md+) -->
<div class="hidden md:block">
<DataTable
:value="filteredRows"
dataKey="id"
:loading="loading"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
scrollable
scrollHeight="400px"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@sort="onSort"
class="pat-datatable"
:rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')"
>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-[6px] bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-base text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
</div>
</div>
</template>
<Column :key="'col-paciente'" field="nome_completo" header="Paciente" v-if="isColVisible('paciente')" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="font-medium truncate">{{ data.nome_completo }}</span>
<Tag v-if="discountMap[data.id]" :value="fmtDiscount(discountMap[data.id])" severity="success" class="shrink-0" style="font-size: 0.7rem; padding: 1px 6px" />
<Tag v-if="insuranceMap[data.id]" :value="insuranceMap[data.id]" severity="info" class="shrink-0" style="font-size: 0.7rem; padding: 1px 6px" />
</div>
<span class="text-base text-color-secondary">ID: {{ shortId(data.id) }}</span>
</div>
</div>
</template>
</Column>
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem">
<template #body="{ data }">
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column header="Telefone" style="width: 16rem" v-if="isColVisible('telefone')" :key="'col-telefone'">
<template #body="{ data }">
<div class="text-base leading-tight">
<div class="font-medium">{{ fmtPhoneBR(data.telefone) }}</div>
<div class="text-base text-color-secondary">{{ data.email_principal || '—' }}</div>
</div>
</template>
</Column>
<Column field="email_principal" header="Email" style="width: 18rem" v-if="isColVisible('email')" :key="'col-email'">
<template #body="{ data }">
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
</template>
</Column>
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem">
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
</Column>
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem">
<template #body="{ data }">{{ data.created_at || '—' }}</template>
</Column>
<Column header="Grupos" style="min-width: 14rem" v-if="isColVisible('grupos')" :key="'col-grupos'">
<template #body="{ data }">
<div v-if="!(data.groups || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag v-for="g in data.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
</div>
</template>
</Column>
<Column header="Tags" style="min-width: 14rem" v-if="isColVisible('tags')" :key="'col-tags'">
<template #body="{ data }">
<div v-if="!(data.tags || []).length" class="text-color-secondary"></div>
<div v-else class="flex flex-wrap gap-2">
<Tag v-for="t in data.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
</div>
</template>
</Column>
<Column :key="'col-acoes'" header="Ações" style="width: 22rem" frozen alignFrozen="right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<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)" />
<PatientActionMenu :patient="data" :hasHistory="historySet.has(data.id)" @updated="fetchAll" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Cards mobile (<md) -->
<div class="md:hidden">
<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">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-[6px] bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-base text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
<Button icon="pi pi-user-plus" label="Cadastrar" @click="goCreateFull" />
</div>
</div>
<div v-else class="flex flex-col gap-3 pb-4">
<div v-for="pat in filteredRows" :key="pat.id" class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<!-- Topo: avatar + nome + status -->
<div class="flex items-center gap-3">
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="font-semibold truncate">{{ pat.nome_completo }}</span>
<Tag v-if="discountMap[pat.id]" :value="fmtDiscount(discountMap[pat.id])" severity="success" class="shrink-0" style="font-size: 0.7rem; padding: 1px 6px" />
<Tag v-if="insuranceMap[pat.id]" :value="insuranceMap[pat.id]" severity="info" class="shrink-0" style="font-size: 0.7rem; padding: 1px 6px" />
</div>
<div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div>
<Tag :value="pat.status" :severity="statusSeverity(pat.status)" />
</div>
<!-- Grupos + Tags -->
<div v-if="(pat.groups || []).length || (pat.tags || []).length" class="mt-3 flex flex-wrap gap-1.5">
<Tag v-for="g in pat.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
<Tag v-for="t in pat.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
</div>
<!-- Ações -->
<div class="mt-3 flex gap-2 justify-end flex-wrap">
<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)" />
<PatientActionMenu :patient="pat" :hasHistory="historySet.has(pat.id)" @updated="fetchAll" />
</div>
</div>
</div>
</div>
<div class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-base text-color-secondary">
<div>
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de <b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
<span v-if="hasActiveFilters"> (filtrado)</span>
</div>
<div class="hidden md:block">Dica: clique em "Ativos/Inativos" no topo para filtrar rápido.</div>
</div>
</TabPanel>
<TabPanel value="espera">
<Card>
<template #content>
<div class="text-color-secondary">Placeholder. Quando você quiser, podemos ligar isso a uma tabela (ex.: patient_waitlist).</div>
</template>
</Card>
</TabPanel>
<TabPanel value="grupos">
<!-- 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="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 -->
<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)" />
<!-- 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 shrink-0 shadow-sm" :style="grpColorStyle(grp)">
{{ (grp.name || '?')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<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' : '' }} · 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 shrink-0" :style="grpColorStyle(grp)">{{ grp.patients.length }}</span>
</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)] 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 flex items-center justify-center text-[9px] font-bold 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>
</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>
<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 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" title="Cadastro Rápido" table-name="patients" name-field="nome_completo" email-field="email_principal" phone-field="telefone" @created="onQuickCreated" />
<PatientProntuario
:key="selectedPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
@edit="
(id) => {
closeProntuario();
goEdit({ id });
}
"
/>
<ConfirmDialog />
<!-- DIALOG SESSÕES DO PACIENTE -->
<Dialog v-model:visible="sessoesOpen" modal :draggable="false" :style="{ width: '700px', maxWidth: '96vw' }" :header="sessoesPaciente ? `Sessões — ${sessoesPaciente.nome_completo}` : 'Sessões'">
<div v-if="sessoesLoading" class="flex justify-center py-8">
<ProgressSpinner />
</div>
<div v-else>
<!-- Recorrências ativas -->
<div v-if="recorrencias.length" class="mb-5">
<div class="text-base font-semibold text-color-secondary mb-2 flex items-center gap-2"><i class="pi pi-sync" /> Recorrências</div>
<div class="flex flex-col gap-2">
<div v-for="r in recorrencias" :key="r.id" class="flex items-center gap-2.5 flex-wrap px-3.5 py-2.5 border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)] text-base">
<Tag :value="r.status === 'ativo' ? 'Ativa' : 'Encerrada'" :severity="r.status === 'ativo' ? 'success' : 'secondary'" />
<span class="text-base">{{ fmtRecorrencia(r) }}</span>
<span class="text-base text-color-secondary ml-auto"> {{ r.start_date }} {{ r.end_date ? `${r.end_date}` : '(em aberto)' }} </span>
</div>
</div>
</div>
<!-- Lista de sessões -->
<div class="text-base font-semibold text-color-secondary mb-2 flex items-center gap-2"><i class="pi pi-calendar" /> Sessões ({{ sessoesLista.length }})</div>
<div v-if="sessoesLista.length === 0" class="text-center py-6 text-color-secondary text-base">Nenhuma sessão encontrada para este paciente.</div>
<div v-else class="flex flex-col gap-2 max-h-[55vh] overflow-y-auto pr-1">
<div v-for="ev in sessoesLista" :key="ev.id" class="px-3.5 py-2.5 border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)]">
<div class="flex items-center gap-3 flex-wrap">
<Tag :value="ev.status || 'agendado'" :severity="statusSessaoSev(ev.status)" />
<span class="font-semibold text-base">{{ fmtDataSessao(ev.inicio_em) }}</span>
<Tag v-if="ev.modalidade" :value="ev.modalidade" severity="secondary" class="ml-auto" />
<span v-if="ev.insurance_plans?.name" class="text-base text-color-secondary flex items-center gap-1">
<i class="pi pi-id-card opacity-60" />{{ ev.insurance_plans.name }}
<span v-if="ev.insurance_guide_number" class="opacity-70">· Guia: {{ ev.insurance_guide_number }}</span>
<span v-if="ev.insurance_value" class="opacity-70">· {{ fmtBRL(ev.insurance_value) }}</span>
</span>
</div>
<div v-if="ev.titulo" class="text-base text-color-secondary mt-1">{{ ev.titulo }}</div>
</div>
</div>
</div>
</Dialog>
</template>
<style scoped>
/* Transição fade (filtros avançados) */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.pat-datatable :deep(tr.row-new-highlight td) {
background-color: #f0fdf4 !important;
}
:global(.app-dark) .pat-datatable :deep(tr.row-new-highlight td) {
background-color: rgba(16, 185, 129, 0.08) !important;
}
</style>