Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização
This commit is contained in:
971
src/features/patients/medicos/MedicosPage.vue
Normal file
971
src/features/patients/medicos/MedicosPage.vue
Normal file
@@ -0,0 +1,971 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/medicos/MedicosPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Menu from 'primevue/menu';
|
||||
|
||||
import {
|
||||
listMedicosWithPatientCounts,
|
||||
createMedico,
|
||||
updateMedico,
|
||||
deleteMedico,
|
||||
fetchPatientsByMedicoNome
|
||||
} from '@/services/Medicos.service.js';
|
||||
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null);
|
||||
const headerSentinelRef = ref(null);
|
||||
const headerStuck = ref(false);
|
||||
let _observer = null;
|
||||
|
||||
// ── Mobile ────────────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null);
|
||||
const searchDlgOpen = ref(false);
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar médico', icon: 'pi pi-plus', command: () => openCreate() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true; } },
|
||||
{ separator: true },
|
||||
...(selectedMedicos.value?.length
|
||||
? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }]
|
||||
: []),
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
]);
|
||||
|
||||
const dt = ref(null);
|
||||
const loading = ref(false);
|
||||
const hasLoaded = ref(false);
|
||||
const medicos = ref([]);
|
||||
const selectedMedicos = ref([]);
|
||||
|
||||
const filters = ref({ global: { value: null, matchMode: 'contains' } });
|
||||
|
||||
// ── Especialidades ────────────────────────────────────────
|
||||
const especialidadesOpts = [
|
||||
{ label: 'Psiquiatria', value: 'Psiquiatria' },
|
||||
{ label: 'Neurologia', value: 'Neurologia' },
|
||||
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
|
||||
{ label: 'Clínica geral', value: 'Clínica geral' },
|
||||
{ label: 'Pediatria', value: 'Pediatria' },
|
||||
{ label: 'Geriatria', value: 'Geriatria' },
|
||||
{ label: 'Endocrinologia', value: 'Endocrinologia' },
|
||||
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
|
||||
{ label: 'Assistência social', value: 'Assistência social' },
|
||||
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
|
||||
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
|
||||
{ label: 'Fisioterapia', value: 'Fisioterapia' },
|
||||
{ label: 'Outra', value: '__outra__' },
|
||||
];
|
||||
|
||||
// ── Quick-stats ───────────────────────────────────────────
|
||||
const quickStats = computed(() => {
|
||||
const all = medicos.value || [];
|
||||
const comPacs = cards.value.length;
|
||||
const totalPacs = all.reduce((s, m) => s + Number(m.patients_count ?? 0), 0);
|
||||
const especialidades = new Set(all.map((m) => m.especialidade).filter(Boolean)).size;
|
||||
return [
|
||||
{ label: 'Total de médicos', value: all.length, cls: '' },
|
||||
{ label: 'Especialidades', value: especialidades, cls: '' },
|
||||
{ label: 'Com pacientes', value: comPacs, cls: comPacs > 0 ? 'qs-ok' : '' },
|
||||
{ label: 'Total encaminhados', value: totalPacs, cls: totalPacs > 0 ? 'qs-ok' : '' }
|
||||
];
|
||||
});
|
||||
|
||||
// ── Dialog Criar/Editar ──────────────────────────────────
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
crm: '',
|
||||
especialidade: '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: '',
|
||||
telefone_pessoal: '',
|
||||
email: '',
|
||||
clinica: '',
|
||||
cidade: '',
|
||||
estado: 'SP',
|
||||
observacoes: '',
|
||||
saving: false,
|
||||
error: ''
|
||||
});
|
||||
|
||||
const especialidadeFinal = computed(() =>
|
||||
dlg.especialidade === '__outra__'
|
||||
? (dlg.especialidade_outra.trim() || null)
|
||||
: (dlg.especialidade || null)
|
||||
);
|
||||
|
||||
// ── Dialog pacientes ──────────────────────────────────────
|
||||
const patientsDialog = reactive({ open: false, loading: false, error: '', medico: null, items: [], search: '' });
|
||||
|
||||
// ── Cards painel lateral ──────────────────────────────────
|
||||
const cards = computed(() =>
|
||||
(medicos.value || [])
|
||||
.filter((m) => Number(m.patients_count ?? 0) > 0)
|
||||
.sort((a, b) => Number(b.patients_count ?? 0) - Number(a.patients_count ?? 0))
|
||||
);
|
||||
|
||||
const patientsDialogFiltered = computed(() => {
|
||||
const s = String(patientsDialog.search || '').trim().toLowerCase();
|
||||
if (!s) return patientsDialog.items || [];
|
||||
return (patientsDialog.items || []).filter(
|
||||
(p) =>
|
||||
String(p.full_name || '').toLowerCase().includes(s) ||
|
||||
String(p.email || '').toLowerCase().includes(s) ||
|
||||
String(p.phone || '').toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
function patientsLabel(n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`;
|
||||
}
|
||||
|
||||
function humanizeError(err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.';
|
||||
const code = err?.code;
|
||||
if (code === '23505' || /duplicate key value/i.test(msg)) return 'Já existe um médico com este CRM.';
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
medicos.value = await listMedicosWithPatientCounts();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
hasLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seleção ───────────────────────────────────────────────
|
||||
function isSelected(row) {
|
||||
return (selectedMedicos.value || []).some((s) => s.id === row.id);
|
||||
}
|
||||
function toggleRowSelection(row, checked) {
|
||||
const sel = selectedMedicos.value || [];
|
||||
selectedMedicos.value = checked
|
||||
? (sel.some((s) => s.id === row.id) ? sel : [...sel, row])
|
||||
: sel.filter((s) => s.id !== row.id);
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────
|
||||
function openCreate() {
|
||||
dlg.open = true;
|
||||
dlg.mode = 'create';
|
||||
dlg.id = '';
|
||||
dlg.nome = '';
|
||||
dlg.crm = '';
|
||||
dlg.especialidade = '';
|
||||
dlg.especialidade_outra = '';
|
||||
dlg.telefone_profissional = '';
|
||||
dlg.telefone_pessoal = '';
|
||||
dlg.email = '';
|
||||
dlg.clinica = '';
|
||||
dlg.cidade = '';
|
||||
dlg.estado = 'SP';
|
||||
dlg.observacoes = '';
|
||||
dlg.error = '';
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
dlg.open = true;
|
||||
dlg.mode = 'edit';
|
||||
dlg.id = row.id;
|
||||
dlg.nome = row.nome || '';
|
||||
dlg.crm = row.crm || '';
|
||||
dlg.especialidade = row.especialidade || '';
|
||||
dlg.especialidade_outra = '';
|
||||
dlg.telefone_profissional = fmtPhone(row.telefone_profissional);
|
||||
dlg.telefone_pessoal = fmtPhone(row.telefone_pessoal);
|
||||
dlg.email = row.email || '';
|
||||
dlg.clinica = row.clinica || '';
|
||||
dlg.cidade = row.cidade || '';
|
||||
dlg.estado = row.estado || 'SP';
|
||||
dlg.observacoes = row.observacoes || '';
|
||||
dlg.error = '';
|
||||
}
|
||||
|
||||
async function saveDialog() {
|
||||
const nome = String(dlg.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlg.error = 'Informe o nome do médico.';
|
||||
return;
|
||||
}
|
||||
if (dlg.especialidade === '__outra__' && !dlg.especialidade_outra.trim()) {
|
||||
dlg.error = 'Informe a especialidade.';
|
||||
return;
|
||||
}
|
||||
|
||||
dlg.saving = true;
|
||||
dlg.error = '';
|
||||
|
||||
const payload = {
|
||||
nome,
|
||||
crm: dlg.crm.trim() || null,
|
||||
especialidade: especialidadeFinal.value,
|
||||
telefone_profissional: dlg.telefone_profissional ? digitsOnly(dlg.telefone_profissional) : null,
|
||||
telefone_pessoal: dlg.telefone_pessoal ? digitsOnly(dlg.telefone_pessoal) : null,
|
||||
email: dlg.email.trim() || null,
|
||||
clinica: dlg.clinica.trim() || null,
|
||||
cidade: dlg.cidade.trim() || null,
|
||||
estado: dlg.estado.trim() || null,
|
||||
observacoes: dlg.observacoes.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createMedico(payload);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico cadastrado.', life: 2500 });
|
||||
} else {
|
||||
await updateMedico(dlg.id, payload);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico atualizado.', life: 2500 });
|
||||
}
|
||||
dlg.open = false;
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
dlg.error = humanizeError(err);
|
||||
} finally {
|
||||
dlg.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteOne(row) {
|
||||
confirm.require({
|
||||
message: `Desativar "Dr(a). ${row.nome}"? O registro será ocultado da listagem.`,
|
||||
header: 'Desativar médico',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteMedico(row.id);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico desativado.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
const sel = selectedMedicos.value || [];
|
||||
if (!sel.length) return;
|
||||
confirm.require({
|
||||
message: `Desativar ${sel.length} médico(s)?`,
|
||||
header: 'Desativar selecionados',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
for (const m of sel) await deleteMedico(m.id);
|
||||
selectedMedicos.value = [];
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médicos desativados.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
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 digitsOnly(v) {
|
||||
return String(v ?? '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function fmtPhone(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;
|
||||
}
|
||||
|
||||
function fmtPhoneDash(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;
|
||||
}
|
||||
|
||||
// ── Modal pacientes ───────────────────────────────────────
|
||||
async function openMedicoPatientsModal(medicoRow) {
|
||||
patientsDialog.open = true;
|
||||
patientsDialog.loading = true;
|
||||
patientsDialog.error = '';
|
||||
patientsDialog.medico = medicoRow;
|
||||
patientsDialog.items = [];
|
||||
patientsDialog.search = '';
|
||||
try {
|
||||
patientsDialog.items = await fetchPatientsByMedicoNome(medicoRow.nome);
|
||||
} catch (err) {
|
||||
patientsDialog.error = humanizeError(err);
|
||||
} finally {
|
||||
patientsDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const editPatientId = ref(null);
|
||||
const editPatientDialog = ref(false);
|
||||
function abrirPaciente(patient) {
|
||||
if (!patient?.id) return;
|
||||
editPatientId.value = String(patient.id);
|
||||
editPatientDialog.value = true;
|
||||
}
|
||||
watch(editPatientDialog, (isOpen) => {
|
||||
if (!isOpen) editPatientId.value = null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting; },
|
||||
{ threshold: 0, rootMargin }
|
||||
);
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
|
||||
fetchAll();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect(); });
|
||||
|
||||
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>
|
||||
<PatientCadastroDialog v-model="editPatientDialog" :patient-id="editPatientId" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
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 -->
|
||||
<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-teal-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-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-heart text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Médicos & Referências</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Gerencie os profissionais de referência que encaminham seus pacientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca desktop -->
|
||||
<div class="hidden xl:flex flex-1 min-w-0 mx-2">
|
||||
<div class="w-64">
|
||||
<FloatLabel variant="on">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText id="medSearch" v-model="filters.global.value" class="w-full" :disabled="loading" />
|
||||
</IconField>
|
||||
<label for="medSearch">Buscar médico...</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button v-if="selectedMedicos?.length" label="Desativar selecionados" icon="pi pi-trash" severity="danger" outlined class="rounded-full" @click="confirmDeleteSelected" />
|
||||
<Button label="Novo médico" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
</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="searchDlgOpen = true" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dialog busca mobile -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Buscar médico" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome, CRM, especialidade..." autofocus />
|
||||
<Button v-if="filters.global.value" icon="pi pi-times" severity="secondary" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<template v-if="loading">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
:class="{
|
||||
'text-green-500': s.cls === 'qs-ok',
|
||||
'text-[var(--text-color)]': !s.cls
|
||||
}"
|
||||
>
|
||||
{{ s.value }}
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
CONTEÚDO: tabela (esq.) + painel lateral (dir.)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
|
||||
<!-- ── TABELA ──────────────────────────────────────── -->
|
||||
<div class="w-full lg:flex-1 min-w-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Cabeçalho da seção -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-[1rem]">Lista de médicos</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-teal-500 text-white text-[1rem] font-bold">
|
||||
{{ medicos.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
ref="dt"
|
||||
v-model:selection="selectedMedicos"
|
||||
:value="medicos"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 25]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} médicos"
|
||||
class="med-datatable"
|
||||
:rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')"
|
||||
>
|
||||
<!-- Seleção -->
|
||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
|
||||
<template #body="{ data }">
|
||||
<Checkbox :binary="true" :modelValue="isSelected(data)" @update:modelValue="toggleRowSelection(data, $event)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.7rem] text-teal-700 shrink-0">
|
||||
{{ initials(data.nome) }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">Dr(a). {{ data.nome }}</div>
|
||||
<div v-if="data.crm" class="text-[0.72rem] text-[var(--text-color-secondary)]">CRM {{ data.crm }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="especialidade" header="Especialidade" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.especialidade" :value="data.especialidade" severity="info" />
|
||||
<span v-else class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Contato" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div v-if="data.telefone_profissional" class="flex items-center gap-1 text-[0.78rem]">
|
||||
<i class="pi pi-phone text-[0.65rem] text-teal-500" />
|
||||
<span>{{ fmtPhoneDash(data.telefone_profissional) }}</span>
|
||||
</div>
|
||||
<div v-if="data.email" class="flex items-center gap-1 text-[0.78rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-envelope text-[0.65rem]" />
|
||||
<span class="truncate max-w-[160px]">{{ data.email }}</span>
|
||||
</div>
|
||||
<span v-if="!data.telefone_profissional && !data.email" class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Local" style="min-width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-0.5 text-[0.78rem]">
|
||||
<div v-if="data.clinica" class="font-medium truncate max-w-[160px]">{{ data.clinica }}</div>
|
||||
<div v-if="data.cidade" class="text-[var(--text-color-secondary)]">
|
||||
{{ data.cidade }}<template v-if="data.estado">/{{ data.estado }}</template>
|
||||
</div>
|
||||
<span v-if="!data.clinica && !data.cidade" class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-[var(--text-color)]">{{ Number(data.patients_count ?? 0) }}</span>
|
||||
<span class="text-[var(--text-color-secondary)] opacity-60 text-[0.73rem]">paciente(s)</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :exportable="false" header="Ações" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1.5 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Desativar'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Nenhum médico encontrado</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Tente limpar o filtro ou cadastre um novo médico.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtro" @click="filters.global.value = null" />
|
||||
<Button icon="pi pi-plus" label="Novo médico" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: médicos com pacientes ─────────── -->
|
||||
<div class="w-full lg:w-[272px] lg:shrink-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header do painel -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-users text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Pacientes por médico</span>
|
||||
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Médicos com encaminhamentos</span>
|
||||
</div>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-teal-500 text-white text-[0.65rem] font-bold shrink-0">{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-2 p-3">
|
||||
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-heart text-2xl opacity-20" />
|
||||
<div class="font-semibold text-[0.8rem]">Nenhum encaminhamento</div>
|
||||
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um médico tiver pacientes encaminhados, ele aparecerá aqui.</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de médicos com pacientes -->
|
||||
<div v-else class="flex flex-col max-h-[480px] overflow-y-auto divide-y divide-[var(--surface-border,#f1f5f9)]">
|
||||
<button
|
||||
v-for="m in cards"
|
||||
:key="m.id"
|
||||
class="flex items-center gap-2.5 px-3.5 py-2.5 text-left w-full bg-transparent border-none hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100 cursor-pointer group"
|
||||
@click="openMedicoPatientsModal(m)"
|
||||
>
|
||||
<!-- Avatar iniciais -->
|
||||
<div class="w-7 h-7 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.6rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors">
|
||||
{{ initials(m.nome) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">Dr(a). {{ m.nome }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
{{ patientsLabel(Number(m.patients_count ?? 0)) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Badge contagem -->
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] shrink-0 bg-teal-500/10 text-teal-600">
|
||||
{{ Number(m.patients_count ?? 0) }}
|
||||
</span>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-teal-600 transition-all duration-150 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div v-if="cards.length" class="px-3.5 py-2 text-[1rem] text-[var(--text-color-secondary)] opacity-50 border-t border-[var(--surface-border,#f1f5f9)] text-center">
|
||||
Clique para ver os pacientes encaminhados
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Criar / Editar médico
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
maximizable
|
||||
class="w-[96vw] max-w-2xl"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-teal-100 text-teal-600 text-[0.8rem] shrink-0">
|
||||
<i class="pi pi-heart" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.mode === 'create' ? 'Novo médico' : `Editar — Dr(a). ${dlg.nome || ''}` }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Cadastrar profissional de referência' : 'Atualizar dados do profissional' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<!-- Nome + CRM -->
|
||||
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText id="dlg_nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
|
||||
</IconField>
|
||||
<label for="dlg_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_crm" v-model="dlg.crm" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_crm">CRM (ex: 123456/SP)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="dlg_esp"
|
||||
v-model="dlg.especialidade"
|
||||
:options="especialidadesOpts"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
filter
|
||||
filterPlaceholder="Buscar especialidade..."
|
||||
:disabled="dlg.saving"
|
||||
/>
|
||||
<label for="dlg_esp">Especialidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade "Outra" -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-active-class="transition-all duration-100 ease-in"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div v-if="dlg.especialidade === '__outra__'">
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_esp_outra" v-model="dlg.especialidade_outra" class="w-full" variant="filled" placeholder="Descreva a especialidade" :disabled="dlg.saving" />
|
||||
<label for="dlg_esp_outra">Qual especialidade? *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Divider contatos -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Telefone profissional -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_prof" v-model="dlg.telefone_profissional" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_prof">Telefone profissional</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Consultório ou clínica.</div>
|
||||
</div>
|
||||
|
||||
<!-- Telefone pessoal -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_pes" v-model="dlg.telefone_pessoal" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_pes">Telefone pessoal / WhatsApp</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Pessoal / WhatsApp.</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope" />
|
||||
<InputText id="dlg_email" v-model="dlg.email" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_email">E-mail profissional</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Divider localização -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Clínica -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-building" />
|
||||
<InputText id="dlg_clinica" v-model="dlg.clinica" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_clinica">Clínica / Hospital</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Cidade + UF -->
|
||||
<div class="grid grid-cols-[1fr_90px] gap-3">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-map-marker" />
|
||||
<InputText id="dlg_cidade" v-model="dlg.cidade" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_cidade">Cidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_uf" v-model="dlg.estado" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_uf">UF</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="dlg_obs" v-model="dlg.observacoes" rows="2" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_obs">Observações internas</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Ex: aceita WhatsApp, convênios atendidos, melhor horário.</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="dlg.error" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
|
||||
<i class="pi pi-exclamation-circle mt-0.5 shrink-0" /> {{ dlg.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
||||
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Salvar médico' : 'Salvar alterações'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Pacientes do médico
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '860px', maxWidth: '95vw' }"
|
||||
:pt="{
|
||||
root: { style: 'border: 4px solid #14b8a6' },
|
||||
header: { style: 'border-bottom: 1px solid rgba(20,184,166,0.19)' }
|
||||
}"
|
||||
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 bg-teal-500">
|
||||
{{ initials(patientsDialog.medico?.nome) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[1rem] font-bold text-teal-600">Dr(a). {{ patientsDialog.medico?.nome }}</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
<template v-if="patientsDialog.medico?.especialidade">{{ patientsDialog.medico.especialidade }} · </template>
|
||||
{{ patientsDialog.items.length }} paciente{{ patientsDialog.items.length !== 1 ? 's' : '' }} encaminhado{{ patientsDialog.items.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Busca + contador -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<IconField class="w-full sm:w-72">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText v-model="patientsDialog.search" placeholder="Buscar paciente..." class="w-full" :disabled="patientsDialog.loading" />
|
||||
</IconField>
|
||||
<span v-if="!patientsDialog.loading" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-teal-500/10 text-teal-600">
|
||||
{{ patientsDialog.items.length }} paciente(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-teal-600"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
|
||||
|
||||
<Message v-else-if="patientsDialog.error" severity="error">{{ patientsDialog.error }}</Message>
|
||||
|
||||
<div v-else>
|
||||
<!-- Empty -->
|
||||
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encaminhado</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Associe pacientes a este médico no cadastro de pacientes.</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<DataTable v-else :value="patientsDialogFiltered" dataKey="id" stripedRows responsiveLayout="scroll" paginator :rows="8" :rowsPerPageOptions="[8, 15, 30]">
|
||||
<Column header="Paciente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
|
||||
<Avatar v-else :label="initials(data.full_name)" shape="circle" style="background: rgba(20,184,166,0.15); color: #14b8a6" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.full_name }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="min-width: 11rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneDash(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined class="!border-teal-500 !text-teal-600" @click="abrirPaciente(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-8 text-center">
|
||||
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-[1rem]">Nenhum resultado</div>
|
||||
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde à busca.</div>
|
||||
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="patientsDialog.search = ''" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" outlined class="rounded-full !border-teal-500 !text-teal-600" @click="patientsDialog.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
Reference in New Issue
Block a user