Copyright, Financeiro, Lançamentos, aprimoramentos de ui
This commit is contained in:
@@ -1,5 +1,25 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/grupos/GruposPacientesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<PatientCadastroDialog
|
||||
v-model="editPatientDialog"
|
||||
:patient-id="editPatientId"
|
||||
/>
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
@@ -88,24 +108,29 @@
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<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,
|
||||
}"
|
||||
>
|
||||
<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
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
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="{
|
||||
'text-green-500': s.cls === 'qs-ok',
|
||||
'text-[var(--text-color)]': !s.cls,
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
|
||||
}"
|
||||
>{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
@@ -163,7 +188,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-[3px] h-5 rounded-sm flex-shrink-0"
|
||||
:style="data.cor ? colorStyle(data.cor) : { background: 'var(--surface-border)' }"
|
||||
:style="effectiveCor(data) ? colorStyle(effectiveCor(data)) : { background: 'var(--surface-border)' }"
|
||||
/>
|
||||
<span class="font-medium">{{ data.nome }}</span>
|
||||
</div>
|
||||
@@ -195,7 +220,7 @@
|
||||
<div class="flex gap-1.5 justify-end">
|
||||
<Button v-if="!data.is_system" icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
|
||||
<Button v-if="!data.is_system" icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
<Button v-if="data.is_system" icon="pi pi-lock" outlined rounded size="small" disabled v-tooltip.top="'Grupo padrão — inalterável'" />
|
||||
<Button v-if="data.is_system" icon="pi pi-palette" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar cor'" @click="openEditColor(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -215,6 +240,7 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: grupos com pacientes ─────────── -->
|
||||
@@ -236,8 +262,13 @@
|
||||
>{{ 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-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)]">
|
||||
<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-users text-2xl opacity-20" />
|
||||
<div class="font-semibold text-[0.8rem]">Nenhuma associação</div>
|
||||
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.</div>
|
||||
@@ -254,7 +285,7 @@
|
||||
<!-- Dot cor -->
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
:style="g.cor ? colorStyle(g.cor) : { background: 'var(--surface-border)' }"
|
||||
:style="effectiveCor(g) ? colorStyle(effectiveCor(g)) : { background: 'var(--surface-border)' }"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">{{ g.nome }}</div>
|
||||
@@ -303,7 +334,7 @@
|
||||
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
|
||||
</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
|
||||
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
|
||||
{{ dlg.isSystem ? 'Grupo padrão — edição de cor' : dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,10 +355,14 @@
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon><i class="pi pi-sitemap" /></InputIcon>
|
||||
<InputText id="grp-nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
|
||||
<InputText id="grp-nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving || dlg.isSystem" @keydown.enter.prevent="saveDialog" />
|
||||
</IconField>
|
||||
<label for="grp-nome">Nome do grupo *</label>
|
||||
<label for="grp-nome">{{ dlg.isSystem ? 'Nome do grupo (padrão)' : 'Nome do grupo *' }}</label>
|
||||
</FloatLabel>
|
||||
<div v-if="dlg.isSystem" class="flex items-center gap-1.5 text-[0.72rem] text-[var(--text-color-secondary)] opacity-60 -mt-1">
|
||||
<i class="pi pi-info-circle text-[0.65rem]" />
|
||||
<span>Grupos padrão do sistema — apenas a cor pode ser alterada</span>
|
||||
</div>
|
||||
|
||||
<!-- Seletor de cor -->
|
||||
<div class="border border-[var(--surface-border,#e2e8f0)] rounded-md bg-[var(--surface-ground,#f8fafc)] p-3.5">
|
||||
@@ -379,7 +414,14 @@
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" :disabled="!String(dlg.nome || '').trim()" @click="saveDialog" />
|
||||
<Button
|
||||
:label="dlg.isSystem ? 'Salvar cor' : 'Salvar'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!dlg.isSystem && !String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -389,32 +431,50 @@
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
:header="patientsDialog.group?.nome ? `Pacientes — ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '860px', maxWidth: '95vw' }"
|
||||
:pt="{
|
||||
root: { style: `border: 4px solid ${patientsGroupHex}` },
|
||||
header: { style: `border-bottom: 1px solid ${patientsGroupHex}30` }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Info grupo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="patientsDialog.group?.cor" class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="colorStyle(patientsDialog.group.cor)" />
|
||||
<span class="text-[var(--text-color-secondary)] text-[1rem]">
|
||||
Grupo: <span class="font-semibold text-[var(--text-color)]">{{ patientsDialog.group?.nome || '—' }}</span>
|
||||
</span>
|
||||
<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 flex-shrink-0"
|
||||
:style="{ background: patientsGroupHex }"
|
||||
>
|
||||
{{ (patientsDialog.group?.nome || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[1rem] font-bold" :style="{ color: patientsGroupHex }">
|
||||
Grupo — {{ patientsDialog.group?.nome }}
|
||||
</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
{{ patientsDialog.items.length }} paciente{{ 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>
|
||||
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
|
||||
<span
|
||||
v-if="!patientsDialog.loading"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||
:style="{ background: `${patientsGroupHex}18`, color: patientsGroupHex }"
|
||||
>{{ patientsDialog.items.length }} paciente(s)</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-[var(--text-color-secondary)]">
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4" :style="{ color: patientsGroupHex }">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
@@ -423,7 +483,10 @@
|
||||
<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-indigo-500/10 text-indigo-500">
|
||||
<div
|
||||
class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md"
|
||||
:style="{ background: `${patientsGroupHex}18`, color: patientsGroupHex }"
|
||||
>
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente neste grupo</div>
|
||||
@@ -444,8 +507,17 @@
|
||||
<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" />
|
||||
<Avatar
|
||||
v-if="data.avatar_url"
|
||||
:image="data.avatar_url"
|
||||
shape="circle"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:label="initials(data.full_name)"
|
||||
shape="circle"
|
||||
:style="{ background: `${patientsGroupHex}25`, color: patientsGroupHex }"
|
||||
/>
|
||||
<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>
|
||||
@@ -462,7 +534,14 @@
|
||||
|
||||
<Column header="Ação" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined @click="abrirPaciente(data)" />
|
||||
<Button
|
||||
label="Abrir"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
outlined
|
||||
:style="{ borderColor: patientsGroupHex, color: patientsGroupHex }"
|
||||
@click="abrirPaciente(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -479,7 +558,14 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="patientsDialog.open = false" />
|
||||
<Button
|
||||
label="Fechar"
|
||||
icon="pi pi-times"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:style="{ borderColor: patientsGroupHex, color: patientsGroupHex }"
|
||||
@click="patientsDialog.open = false"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
@@ -487,7 +573,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
@@ -503,6 +589,9 @@ import {
|
||||
deleteGroup
|
||||
} from '@/services/GruposPacientes.service.js'
|
||||
|
||||
import { getSysGroupColor, setSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js'
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
@@ -529,6 +618,7 @@ const grpMobileMenuItems = computed(() => [
|
||||
|
||||
const dt = ref(null)
|
||||
const loading = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const groups = ref([])
|
||||
const selectedGroups = ref([])
|
||||
|
||||
@@ -550,8 +640,22 @@ const quickStats = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
function systemDefaultColor (row) {
|
||||
return getSystemGroupDefaultColor(row.nome).replace('#', '')
|
||||
}
|
||||
|
||||
function effectiveCor (row) {
|
||||
if (row.is_system) {
|
||||
const stored = getSysGroupColor(row.id)
|
||||
if (stored) return stored.replace('#', '')
|
||||
if (row.cor) return row.cor
|
||||
return systemDefaultColor(row)
|
||||
}
|
||||
return row.cor || ''
|
||||
}
|
||||
|
||||
// ── Dialog ────────────────────────────────────────────────
|
||||
const dlg = reactive({ open: false, mode: 'create', id: '', nome: '', cor: '', saving: false })
|
||||
const dlg = reactive({ open: false, mode: 'create', id: '', nome: '', cor: '', saving: false, isSystem: false })
|
||||
|
||||
const dlgPresetColors = [
|
||||
{ bg: '6366f1', name: 'Índigo' },
|
||||
@@ -609,6 +713,12 @@ function colorStyle (cor) {
|
||||
return { background: hex }
|
||||
}
|
||||
|
||||
const patientsGroupHex = computed(() => {
|
||||
if (!patientsDialog.group) return '#6366f1'
|
||||
const raw = effectiveCor(patientsDialog.group)
|
||||
return raw ? (raw.startsWith('#') ? raw : `#${raw}`) : '#6366f1'
|
||||
})
|
||||
|
||||
function humanizeError (err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
|
||||
const code = err?.code
|
||||
@@ -628,6 +738,7 @@ async function fetchAll () {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
hasLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,18 +770,27 @@ function toggleRowSelection (row, checked) {
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────
|
||||
function openCreate () { dlg.open = true; dlg.mode = 'create'; dlg.id = ''; dlg.nome = ''; dlg.cor = '' }
|
||||
function openEdit (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = row.cor || '' }
|
||||
function openCreate () { dlg.open = true; dlg.mode = 'create'; dlg.id = ''; dlg.nome = ''; dlg.cor = ''; dlg.isSystem = false }
|
||||
function openEdit (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = row.cor || ''; dlg.isSystem = false }
|
||||
function openEditColor (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = effectiveCor(row); dlg.isSystem = true }
|
||||
|
||||
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 }
|
||||
if (!dlg.isSystem) {
|
||||
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 }
|
||||
}
|
||||
const corRaw = String(dlg.cor || '').trim()
|
||||
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
|
||||
dlg.saving = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
if (dlg.isSystem) {
|
||||
setSysGroupColor(dlg.id, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Cor do grupo atualizada.', life: 2500 })
|
||||
dlg.open = false
|
||||
await fetchAll()
|
||||
return
|
||||
} else if (dlg.mode === 'create') {
|
||||
await createGroup(nome, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
|
||||
} else {
|
||||
@@ -760,7 +880,10 @@ async function openGroupPatientsModal (groupRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) { router.push(`/features/patients/cadastro/${patient.id}`) }
|
||||
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`
|
||||
|
||||
Reference in New Issue
Block a user