1458 lines
74 KiB
Vue
1458 lines
74 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| 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) — só 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>
|