Copyright, Financeiro, Lançamentos, aprimoramentos de ui

This commit is contained in:
Leonardo
2026-03-21 08:05:40 -03:00
parent 29ed349cf2
commit a89d1f5560
268 changed files with 58870 additions and 1752 deletions
@@ -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`