This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
@@ -1,32 +1,79 @@
<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>
<Toast />
<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>
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="grp-sentinel" />
<div class="flex flex-col lg:flex-row gap-4">
<!-- Hero Header sticky -->
<div ref="headerEl" class="grp-hero mx-3 md:mx-5 mb-4" :class="{ 'grp-hero--stuck': headerStuck }">
<div class="grp-hero__blobs" aria-hidden="true">
<div class="grp-hero__blob grp-hero__blob--1" />
<div class="grp-hero__blob grp-hero__blob--2" />
</div>
<!-- Linha 1 -->
<div class="grp-hero__row1">
<div class="grp-hero__brand">
<div class="grp-hero__icon"><i class="pi pi-sitemap text-lg" /></div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="grp-hero__title">Grupos</div>
<Tag :value="`${groups.length}`" severity="secondary" />
</div>
<div class="grp-hero__sub">Organize seus pacientes por grupos temáticos ou clínicos</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
v-if="selectedGroups?.length"
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
class="rounded-full"
@click="confirmDeleteSelected"
/>
<Button label="Novo" 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" title="Recarregar" @click="fetchAll" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => grpMobileMenuRef.toggle(e)" />
<Menu ref="grpMobileMenuRef" :model="grpMobileMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="grp-hero__divider my-2" />
<!-- Linha 2: busca (oculta no mobile) -->
<div class="grp-hero__row2">
<InputGroup class="w-72">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Buscar grupo..." :disabled="loading" />
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filters.global.value = null" />
</InputGroup>
</div>
</div>
<!-- Dialog de busca (mobile) -->
<Dialog v-model:visible="grpSearchDlgOpen" modal :draggable="false" header="Buscar grupo" 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 do grupo..." autofocus />
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" @click="filters.global.value = null" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="grpSearchDlgOpen = false" />
</template>
</Dialog>
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
@@ -48,16 +95,9 @@
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 class="flex items-center gap-2">
<span class="font-medium">Lista de Grupos</span>
<Tag :value="`${groups.length} grupos`" severity="secondary" />
</div>
</template>
@@ -73,7 +113,18 @@
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
<Column field="nome" header="Nome" sortable style="min-width: 16rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<span
v-if="data.cor"
class="inline-block w-3 h-3 rounded-full flex-shrink-0"
:style="colorStyle(data.cor)"
/>
<span>{{ data.nome }}</span>
</div>
</template>
</Column>
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }">
@@ -116,14 +167,24 @@
outlined
rounded
disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
title="Grupo padrão do sistema (inalterável)"
/>
</div>
</template>
</Column>
<template #empty>
Nenhum grupo encontrado.
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum grupo encontrado</div>
<div class="mt-1 text-sm text-color-secondary">Tente limpar o filtro ou crie um novo grupo.</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="Criar grupo" @click="openCreate" />
</div>
</div>
</template>
</DataTable>
</template>
@@ -192,31 +253,118 @@
<!-- 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>
v-model:visible="dlg.open"
modal
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
class="grp-dialog w-[96vw] max-w-lg"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
>
<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="grp-dlg-dot shrink-0" :style="{ backgroundColor: dlgPreviewColor }" />
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
</div>
<div class="text-xs opacity-50">
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
</div>
</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>
<div class="flex items-center gap-2 shrink-0">
<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"
/>
</div>
</div>
</template>
<!-- Banner de preview -->
<div class="grp-dlg-banner" :style="{ backgroundColor: dlgPreviewColor }">
<span class="grp-dlg-banner__pill">{{ dlg.nome || 'Nome do grupo' }}</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 p-4">
<!-- Nome -->
<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"
/>
</IconField>
<label for="grp-nome">Nome do grupo *</label>
</FloatLabel>
<!-- Cor -->
<div class="grp-dlg-section">
<div class="grp-dlg-section__label">Cor</div>
<div class="grp-dlg-palette">
<button
v-for="p in dlgPresetColors"
:key="p.bg"
class="grp-dlg-swatch"
:class="{ 'grp-dlg-swatch--active': dlg.cor === p.bg }"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="dlg.saving"
@click="dlg.cor = p.bg"
>
<i v-if="dlg.cor === p.bg" class="pi pi-check grp-dlg-swatch__check" />
</button>
<!-- Custom ColorPicker -->
<div class="grp-dlg-swatch grp-dlg-swatch--custom" title="Cor personalizada">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
</div>
<!-- Limpar cor -->
<button
v-if="dlg.cor"
class="grp-dlg-swatch grp-dlg-swatch--clear"
title="Sem cor"
:disabled="dlg.saving"
@click="dlg.cor = ''"
>
<i class="pi pi-times text-xs" />
</button>
</div>
</div>
</div>
</Dialog>
<!-- DIALOG PACIENTES (com botão Abrir) -->
<Dialog
@@ -253,8 +401,12 @@
</Message>
<div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
Nenhum paciente associado a este grupo.
<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-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente neste grupo</div>
<div class="mt-1 text-sm text-color-secondary">Associe pacientes a este grupo na página de pacientes.</div>
</div>
<div v-else>
@@ -299,7 +451,16 @@
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum resultado</div>
<div class="mt-1 text-sm text-color-secondary">Nenhum paciente corresponde a "{{ patientsDialog.search }}".</div>
<div class="mt-4">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="patientsDialog.search = ''" />
</div>
</div>
</template>
</DataTable>
</div>
@@ -311,17 +472,17 @@
</template>
</Dialog>
<ConfirmDialog />
</div>
<ConfirmDialog />
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client'
import {
@@ -335,6 +496,24 @@ 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 menu ───────────────────────────────────────────
const grpMobileMenuRef = ref(null)
const grpSearchDlgOpen = ref(false)
const grpMobileMenuItems = computed(() => [
{ label: 'Adicionar grupo', icon: 'pi pi-plus', command: () => openCreate() },
{ label: 'Buscar', icon: 'pi pi-search', command: () => { grpSearchDlgOpen.value = true } },
{ separator: true },
...(selectedGroups.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 groups = ref([])
@@ -350,9 +529,30 @@ const dlg = reactive({
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
cor: '',
saving: false
})
const dlgPresetColors = [
{ bg: '6366f1', name: 'Índigo' },
{ bg: '8b5cf6', name: 'Violeta' },
{ bg: 'ec4899', name: 'Rosa' },
{ bg: 'ef4444', name: 'Vermelho' },
{ bg: 'f97316', name: 'Laranja' },
{ bg: 'eab308', name: 'Amarelo' },
{ bg: '22c55e', name: 'Verde' },
{ bg: '14b8a6', name: 'Teal' },
{ bg: '3b82f6', name: 'Azul' },
{ bg: '06b6d4', name: 'Ciano' },
{ bg: '64748b', name: 'Ardósia' },
{ bg: '292524', name: 'Escuro' },
]
const dlgPreviewColor = computed(() => {
if (!dlg.cor) return '#64748b'
return dlg.cor.startsWith('#') ? dlg.cor : `#${dlg.cor}`
})
const patientsDialog = reactive({
open: false,
loading: false,
@@ -428,6 +628,12 @@ function patientsLabel (n) {
return n === 1 ? '1 paciente' : `${n} pacientes`
}
function colorStyle (cor) {
if (!cor) return {}
const hex = String(cor).startsWith('#') ? cor : '#' + cor
return { background: hex }
}
function humanizeError (err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
const code = err?.code
@@ -482,6 +688,7 @@ function openCreate () {
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
dlg.cor = ''
}
function openEdit (row) {
@@ -489,6 +696,7 @@ function openEdit (row) {
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome
dlg.cor = row.cor || ''
}
async function saveDialog () {
@@ -502,13 +710,16 @@ async function saveDialog () {
return
}
const corRaw = String(dlg.cor || '').trim()
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
dlg.saving = true
try {
if (dlg.mode === 'create') {
await createGroup(nome)
await createGroup(nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
await updateGroup(dlg.id, nome)
await updateGroup(dlg.id, nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
}
dlg.open = false
@@ -653,12 +864,125 @@ function abrirPaciente (patient) {
router.push(`/features/patients/cadastro/${patient.id}`)
}
onMounted(fetchAll)
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() })
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
/* ── Hero ────────────────────────────────────────── */
.grp-sentinel { height: 1px; }
.grp-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.grp-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.grp-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.grp-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.grp-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(16,185,129,0.10); }
.grp-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.grp-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
.grp-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.grp-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.grp-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.grp-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.grp-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.grp-hero__divider,
.grp-hero__row2 { display: none; }
}
/* ── Dialog ──────────────────────────────────────── */
.grp-dlg-dot {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
transition: background-color 0.2s ease;
}
.grp-dlg-banner {
height: 72px;
display: flex; align-items: center; justify-content: center;
transition: background-color 0.25s ease;
}
.grp-dlg-banner__pill {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0.35rem 1.1rem;
background: rgba(0,0,0,0.15);
border-radius: 999px;
backdrop-filter: blur(4px);
color: #fff;
}
.grp-dlg-section {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
background: var(--surface-card);
padding: 1rem;
}
.grp-dlg-section__label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.45; margin-bottom: 0.75rem;
}
.grp-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.grp-dlg-swatch {
width: 28px; height: 28px; border-radius: 50%;
border: 2px solid transparent;
display: grid; place-items: center;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
}
.grp-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
.grp-dlg-swatch--active {
border-color: var(--surface-0, #fff);
box-shadow: 0 0 0 2px var(--text-color);
}
.grp-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
.grp-dlg-swatch--custom {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
overflow: hidden;
}
.grp-dlg-swatch--custom :deep(.p-colorpicker-preview) {
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
}
.grp-dlg-swatch--clear {
background: var(--surface-border);
color: var(--text-color-secondary);
}
/* Fade */
.fade-enter-active, .fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>