Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda

This commit is contained in:
Leonardo
2026-02-22 17:56:01 -03:00
parent 6eff67bf22
commit 89b4ecaba1
77 changed files with 9433 additions and 1995 deletions
@@ -0,0 +1,664 @@
<template>
<div class="p-4">
<!-- TOOLBAR (padrão Sakai CRUD) -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
<small class="text-color-secondary mt-1">
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!selectedGroups || !selectedGroups.length"
@click="confirmDeleteSelected"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
v-model:selection="selectedGroups"
:value="groups"
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} grupos"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Grupos</span>
<Tag :value="`${groups.length} grupos`" severity="secondary" />
</div>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="filters.global.value" placeholder="Buscar grupos..." class="w-64" />
</IconField>
</div>
</template>
<!-- seleção (desabilita grupos do sistema) -->
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
<template #body="{ data }">
<Checkbox
:binary="true"
:disabled="!!data.is_system"
:modelValue="isSelected(data)"
@update:modelValue="toggleRowSelection(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data.is_system ? 'Padrão' : 'Criado por você'"
:severity="data.is_system ? 'info' : 'success'"
/>
</template>
</Column>
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-color-secondary">
{{ patientsLabel(Number(data.patients_count ?? data.patient_count ?? 0)) }}
</span>
</template>
</Column>
<Column :exportable="false" header="Ações" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex justify-end gap-2">
<Button
v-if="!data.is_system"
icon="pi pi-pencil"
outlined
rounded
@click="openEdit(data)"
/>
<Button
v-if="!data.is_system"
icon="pi pi-trash"
outlined
rounded
severity="danger"
@click="confirmDeleteOne(data)"
/>
<Button
v-if="data.is_system"
icon="pi pi-lock"
outlined
rounded
disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
/>
</div>
</template>
</Column>
<template #empty>
Nenhum grupo encontrado.
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: CARDS -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Pacientes por grupo</template>
<template #subtitle>Os cards aparecem apenas quando pacientes associados.</template>
<template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
<i class="pi pi-users text-3xl"></i>
<div class="mt-1 font-medium">Sem pacientes associados</div>
<small class="text-color-secondary">
Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.
</small>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="g in cards"
:key="g.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = g.id"
@mouseleave="hovered = null"
>
<div class="flex justify-between items-start gap-3">
<div class="min-w-0">
<div class="font-bold truncate max-w-[230px]">
{{ g.nome }}
</div>
<small class="text-color-secondary">
{{ patientsLabel(Number(g.patients_count ?? g.patient_count ?? 0)) }}
</small>
</div>
<Tag
:value="g.is_system ? 'Padrão' : 'Criado por você'"
:severity="g.is_system ? 'info' : 'success'"
/>
</div>
<Transition name="fade">
<div
v-if="hovered === g.id"
class="absolute inset-0 rounded-xl bg-emerald-500/15 backdrop-blur-sm flex items-center justify-center"
>
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="!(g.patients_count ?? g.patient_count)"
@click="openGroupPatientsModal(g)"
/>
</div>
</Transition>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<div class="flex flex-col gap-3">
<div>
<label class="block mb-2">Nome do Grupo</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">
Grupos Padrão são do sistema e não podem ser editados.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
:loading="dlg.saving"
@click="saveDialog"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
</Dialog>
<!-- DIALOG PACIENTES (com botão Abrir) -->
<Dialog
v-model:visible="patientsDialog.open"
:header="patientsDialog.group?.nome ? `Pacientes do grupo: ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
modal
:style="{ width: '900px', maxWidth: '95vw' }"
>
<div class="flex flex-col gap-3">
<div class="text-color-secondary">
Grupo: <span class="font-medium text-color">{{ patientsDialog.group?.nome || '—' }}</span>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="patientsDialog.search"
placeholder="Buscar paciente..."
class="w-full"
:disabled="patientsDialog.loading"
/>
</IconField>
<div class="flex items-center gap-2 justify-end">
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
</div>
</div>
<div v-if="patientsDialog.loading" class="text-color-secondary">Carregando</div>
<Message v-else-if="patientsDialog.error" severity="error">
{{ patientsDialog.error }}
</Message>
<div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
Nenhum paciente associado a este grupo.
</div>
<div v-else>
<DataTable
: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" />
<div class="min-w-0">
<div class="font-medium truncate max-w-[420px]">{{ data.full_name }}</div>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhone(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
</template>
</DataTable>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="patientsDialog.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox'
import { supabase } from '@/lib/supabase/client'
import {
listGroupsWithCounts,
createGroup,
updateGroup,
deleteGroup
} from '@/services/GruposPacientes.service.js'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const loading = ref(false)
const groups = ref([])
const selectedGroups = ref([])
const hovered = ref(null)
const filters = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
saving: false
})
const patientsDialog = reactive({
open: false,
loading: false,
error: '',
group: null,
items: [],
search: ''
})
function applyRealCountsToGroups (groupsArr, countMap) {
return (groupsArr || []).map(g => ({
...g,
patients_count: Number(countMap[g.id] || 0) // força a verdade aqui
}))
}
async function fetchRealGroupCountsForOwner () {
const ownerId = (await supabase.auth.getUser())?.data?.user?.id
if (!ownerId) throw new Error('Sessão inválida.')
// Busca todas as associações (group <-> patient) apenas de pacientes do owner logado
const { data, error } = await supabase
.from('patient_group_patient')
.select(`
patient_group_id,
patient:patients!inner (
id,
owner_id
)
`)
.eq('patient.owner_id', ownerId)
if (error) throw error
// Conta em JS por group_id
const map = Object.create(null)
for (const row of (data || [])) {
const gid = row.patient_group_id
if (!gid) continue
map[gid] = (map[gid] || 0) + 1
}
return map
}
const cards = computed(() => {
const arr = groups.value || []
return arr
.filter(g => {
const raw = g.patients_count ?? g.patient_count ?? 0
const n = Number.parseInt(String(raw), 10)
return Number.isFinite(n) && n > 0
})
.sort((a, b) => {
const na = Number.parseInt(String(a.patients_count ?? a.patient_count ?? 0), 10) || 0
const nb = Number.parseInt(String(b.patients_count ?? b.patient_count ?? 0), 10) || 0
return nb - na
})
})
const patientsDialogFiltered = computed(() => {
const s = String(patientsDialog.search || '').trim().toLowerCase()
if (!s) return patientsDialog.items || []
return (patientsDialog.items || []).filter(p => {
const name = String(p.full_name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.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 grupo com esse nome (para você). Tente outro nome.'
}
if (/Grupo padrão/i.test(msg)) {
return 'Esse é um grupo padrão do sistema e não pode ser alterado.'
}
return msg
}
async function fetchAll () {
loading.value = true
try {
// 1) carrega grupos (com ou sem count vindo do service)
const baseGroups = await listGroupsWithCounts()
// 2) recalcula counts reais (por owner) e sobrescreve
const realCountMap = await fetchRealGroupCountsForOwner()
groups.value = applyRealCountsToGroups(baseGroups, realCountMap)
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
loading.value = false
}
}
/* -------------------------------
Seleção: ignora grupos do sistema
-------------------------------- */
function isSelected (row) {
return (selectedGroups.value || []).some(s => s.id === row.id)
}
function toggleRowSelection (row, checked) {
if (row.is_system) return
const sel = selectedGroups.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) selectedGroups.value = [...sel, row]
} else {
selectedGroups.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
CRUD
-------------------------------- */
function openCreate () {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
}
function openEdit (row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome
}
async function saveDialog () {
const nome = String(dlg.nome || '').trim()
if (!nome) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 })
return
}
if (nome.length < 2) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 })
return
}
dlg.saving = true
try {
if (dlg.mode === 'create') {
await createGroup(nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
await updateGroup(dlg.id, nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
}
dlg.open = false
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
dlg.saving = false
}
}
function confirmDeleteOne (row) {
confirm.require({
message: `Excluir "${row.nome}"?`,
header: 'Excluir grupo',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await deleteGroup(row.id)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo excluído.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
function confirmDeleteSelected () {
const sel = selectedGroups.value || []
if (!sel.length) return
const deletables = sel.filter(g => !g.is_system)
const blocked = sel.filter(g => g.is_system)
if (!deletables.length) {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Os itens selecionados são grupos do sistema e não podem ser excluídos.',
life: 3500
})
return
}
const msgBlocked = blocked.length ? ` (${blocked.length} grupo(s) padrão serão ignorados)` : ''
confirm.require({
message: `Excluir ${deletables.length} grupo(s) selecionado(s)?${msgBlocked}`,
header: 'Excluir selecionados',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
for (const g of deletables) await deleteGroup(g.id)
selectedGroups.value = []
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Exclusão concluída.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
/* -------------------------------
Helpers (avatar/telefone)
-------------------------------- */
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 onlyDigits (v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhone (v) {
const d = onlyDigits(v)
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 do Grupo
-------------------------------- */
async function openGroupPatientsModal (groupRow) {
patientsDialog.open = true
patientsDialog.loading = true
patientsDialog.error = ''
patientsDialog.group = groupRow
patientsDialog.items = []
patientsDialog.search = ''
try {
const { data, error } = await supabase
.from('patient_group_patient')
.select(`
patient_id,
patient:patients (
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('patient_group_id', groupRow.id)
if (error) throw error
const patients = (data || [])
.map(r => r.patient)
.filter(Boolean)
patientsDialog.items = patients
.map(p => ({
id: p.id,
full_name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'))
} catch (err) {
patientsDialog.error = humanizeError(err)
} finally {
patientsDialog.loading = false
}
}
function abrirPaciente (patient) {
router.push(`/features/patients/cadastro/${patient.id}`)
}
onMounted(fetchAll)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>