Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1625
-148
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaEmbed — Wrapper genérico pra embedar pages tradicionais dentro
|
||||
* do MelissaLayout (Onda 1 da migração).
|
||||
*
|
||||
* Usado pra páginas que ainda não viraram Melissa Pages dedicadas mas
|
||||
* que o user precisa acessar sem sair do overlay Melissa: Financeiro,
|
||||
* Documents, Agendamentos Recebidos, Online Scheduling, etc.
|
||||
*
|
||||
* Diferença pra MelissaConfiguracoes:
|
||||
* - MelissaConfiguracoes tem aside com sidebar de seções (hub de configs)
|
||||
* - MelissaEmbed é 1-coluna full-width (1 page só, sem nav lateral)
|
||||
*
|
||||
* Padrão: hero glass sticky no topo + Suspense + <component :is>.
|
||||
*
|
||||
* Reusa o mesmo Teleport target #cfg-page-actions pra que pages que
|
||||
* injetam ações no header da ConfiguracoesPage tradicional não quebrem.
|
||||
*/
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Key da rota /melissa/:secao — determina qual page embedar
|
||||
secaoRota: { type: String, required: true }
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
// ── Catálogo de seções embedáveis ──────────────────────────────
|
||||
// Cada entry tem label, descrição, ícone e o componente assíncrono.
|
||||
// Mantido neste arquivo (não no MelissaLayout) pra que adicionar uma
|
||||
// nova page aqui não exija mexer no parent.
|
||||
const EMBED_MAP = {
|
||||
'financeiro': {
|
||||
label: 'Financeiro',
|
||||
desc: 'Visão geral, recebíveis e indicadores do mês.',
|
||||
icon: 'pi pi-wallet',
|
||||
comp: defineAsyncComponent(() => import('@/features/financeiro/pages/FinanceiroDashboardPage.vue'))
|
||||
},
|
||||
'financeiro-lancamentos': {
|
||||
label: 'Lançamentos financeiros',
|
||||
desc: 'Lista detalhada de cobranças, pagamentos e recebimentos.',
|
||||
icon: 'pi pi-list',
|
||||
comp: defineAsyncComponent(() => import('@/features/financeiro/pages/FinanceiroPage.vue'))
|
||||
},
|
||||
'documentos': {
|
||||
label: 'Documentos',
|
||||
desc: 'Documentos clínicos do tenant — geração, edição e histórico.',
|
||||
icon: 'pi pi-file',
|
||||
comp: defineAsyncComponent(() => import('@/features/documents/DocumentsListPage.vue'))
|
||||
},
|
||||
'documentos-templates': {
|
||||
label: 'Templates de documentos',
|
||||
desc: 'Modelos reutilizáveis pra prontuários e relatórios.',
|
||||
icon: 'pi pi-file-edit',
|
||||
comp: defineAsyncComponent(() => import('@/features/documents/DocumentTemplatesPage.vue'))
|
||||
},
|
||||
'agendamentos-recebidos': {
|
||||
label: 'Agendamentos recebidos',
|
||||
desc: 'Solicitações vindas do agendador online à espera de confirmação.',
|
||||
icon: 'pi pi-inbox',
|
||||
comp: defineAsyncComponent(() => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'))
|
||||
},
|
||||
'online-scheduling': {
|
||||
label: 'Agendador online',
|
||||
desc: 'Configure o link público pra pacientes solicitarem horários.',
|
||||
icon: 'pi pi-calendar-clock',
|
||||
comp: defineAsyncComponent(() => import('@/views/pages/therapist/OnlineSchedulingPage.vue'))
|
||||
},
|
||||
'relatorios': {
|
||||
label: 'Relatórios',
|
||||
desc: 'Indicadores e relatórios do tenant — clínico e financeiro.',
|
||||
icon: 'pi pi-chart-bar',
|
||||
comp: defineAsyncComponent(() => import('@/views/pages/therapist/RelatoriosPage.vue'))
|
||||
},
|
||||
'notificacoes': {
|
||||
label: 'Notificações',
|
||||
desc: 'Histórico de notificações enviadas (WhatsApp, e-mail, SMS).',
|
||||
icon: 'pi pi-bell',
|
||||
comp: defineAsyncComponent(() => import('@/views/pages/therapist/NotificationsHistoryPage.vue'))
|
||||
},
|
||||
'link-externo': {
|
||||
label: 'Link externo de cadastro',
|
||||
desc: 'Link público pra pacientes preencherem o cadastro online.',
|
||||
icon: 'pi pi-share-alt',
|
||||
comp: defineAsyncComponent(() => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue'))
|
||||
}
|
||||
};
|
||||
|
||||
const info = computed(() => EMBED_MAP[props.secaoRota] || null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="me-page">
|
||||
<header class="me-page__head">
|
||||
<div class="me-page__title">
|
||||
<i :class="info?.icon || 'pi pi-file'" />
|
||||
<span>{{ info?.label || 'Página' }}</span>
|
||||
</div>
|
||||
<div class="me-page__actions">
|
||||
<button class="me-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="me-body">
|
||||
<!-- Hero contextual (igual ao mcfg-embed-hero) -->
|
||||
<div v-if="info" class="me-hero">
|
||||
<div class="me-hero__icon">
|
||||
<i :class="info.icon" />
|
||||
</div>
|
||||
<div class="me-hero__text">
|
||||
<div class="me-hero__title">{{ info.label }}</div>
|
||||
<div class="me-hero__desc">{{ info.desc }}</div>
|
||||
</div>
|
||||
<!-- Teleport target compartilhado com ConfiguracoesPage:
|
||||
algumas pages que migram fazem <Teleport to="#cfg-page-actions">.
|
||||
Mantemos o id pra não quebrar. -->
|
||||
<div id="cfg-page-actions" class="me-hero__actions"></div>
|
||||
</div>
|
||||
|
||||
<!-- Embed dinâmico -->
|
||||
<div class="me-content">
|
||||
<Suspense v-if="info">
|
||||
<template #default>
|
||||
<component :is="info.comp" :key="secaoRota" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="me-loading">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
<span>Carregando…</span>
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
<div v-else class="me-loading">
|
||||
<i class="pi pi-exclamation-triangle" />
|
||||
<span>Seção desconhecida: {{ secaoRota }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ═════ Container glass ═════ */
|
||||
.me-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: me-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes me-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ═════ Header ═════ */
|
||||
.me-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.me-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.me-page__title > i { color: var(--m-text-muted); font-size: 0.95rem; }
|
||||
.me-page__title > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.me-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.me-close {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.me-close:hover { background: var(--m-bg-soft-hover); }
|
||||
.me-close > i { font-size: 0.85rem; }
|
||||
|
||||
/* ═════ Body ═════ */
|
||||
.me-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.me-body::-webkit-scrollbar { width: 6px; }
|
||||
.me-body::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
|
||||
|
||||
/* Hero contextual (mesmo padrão do mcfg-embed-hero) */
|
||||
.me-hero {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.me-hero__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--m-accent-soft);
|
||||
color: var(--m-accent);
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.me-hero__icon > i { font-size: 0.92rem; }
|
||||
.me-hero__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.me-hero__title {
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.me-hero__desc {
|
||||
font-size: 0.74rem;
|
||||
color: var(--m-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.me-hero__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wrapper que dá padding ao conteúdo embedado */
|
||||
.me-content {
|
||||
padding: 16px 18px 28px;
|
||||
}
|
||||
|
||||
/* Loading do Suspense */
|
||||
.me-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 20px;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.me-loading > i { font-size: 1.2rem; color: var(--m-accent); }
|
||||
|
||||
/* Mobile (<lg) */
|
||||
@media (max-width: 1023px) {
|
||||
.me-content { padding: 12px; }
|
||||
.me-hero { padding: 10px 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -232,9 +232,11 @@ function modalidadeIcon(mod) {
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
/* Blur XS — bem leve. O resumo continua legível atrás, só ganha
|
||||
um leve "tilt-shift" pra direcionar o olhar pro panel. */
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
backdrop-filter: blur(4px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(4px) saturate(110%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -429,8 +431,8 @@ function modalidadeIcon(mod) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
/* Light mode — overlay menos escuro */
|
||||
/* Light mode — overlay ainda mais discreto */
|
||||
html:not(.app-dark) .evento-layer {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
background: rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaGrupos — CRUD de grupos de pacientes dentro de Melissa.
|
||||
* Segue blueprint melissa-page-blueprint.md.
|
||||
*
|
||||
* Layout 2-col (espelha MelissaTags):
|
||||
* - COL 1 — Aside (~280px): stats + busca
|
||||
* - COL 2 — Lista de grupos (cor + nome + contagem de pacientes)
|
||||
*
|
||||
* Tabela: patient_groups, vínculo: patient_group_patient.
|
||||
* Sem view agregada — contagem feita no client após carregar vínculos.
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// ── Breakpoints + drawer ───────────────────────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const grupos = ref([]);
|
||||
const counts = ref(new Map()); // groupId → patient count
|
||||
const busca = ref('');
|
||||
const carregandoInicial = computed(
|
||||
() => loading.value && grupos.value.length === 0
|
||||
);
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data?.user?.id) throw new Error('Sessão não inicializada.');
|
||||
return data.user.id;
|
||||
}
|
||||
async function getTenantId() {
|
||||
if (typeof tenantStore.ensureLoaded === 'function') await tenantStore.ensureLoaded();
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
|
||||
if (!tid) throw new Error('Tenant não inicializado.');
|
||||
return tid;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getTenantId();
|
||||
|
||||
const [{ data: gData, error: gErr }, { data: vData }] = await Promise.all([
|
||||
supabase.from('patient_groups')
|
||||
.select('id, owner_id, tenant_id, nome, cor, is_system, is_active, created_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome', { ascending: true }),
|
||||
supabase.from('patient_group_patient').select('patient_group_id')
|
||||
]);
|
||||
if (gErr) throw gErr;
|
||||
|
||||
// Conta vínculos por grupo no client
|
||||
const map = new Map();
|
||||
for (const v of vData || []) {
|
||||
const id = v.patient_group_id;
|
||||
map.set(id, (map.get(id) || 0) + 1);
|
||||
}
|
||||
counts.value = map;
|
||||
grupos.value = (gData || []).map((g) => ({
|
||||
...g,
|
||||
pacientes_count: map.get(g.id) || 0
|
||||
}));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar grupos', detail: e?.message, life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = grupos.value;
|
||||
const ativos = all.filter((g) => g.is_active !== false).length;
|
||||
const sistema = all.filter((g) => g.is_system).length;
|
||||
const meus = all.filter((g) => !g.is_system).length;
|
||||
const emUso = all.filter((g) => g.pacientes_count > 0).length;
|
||||
return [
|
||||
{ key: 'total', label: 'Total', value: all.length, cls: 'neutral' },
|
||||
{ key: 'meus', label: 'Meus', value: meus, cls: meus > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'uso', label: 'Em uso', value: emUso, cls: emUso > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'sistema', label: 'Sistema', value: sistema, cls: 'neutral' }
|
||||
];
|
||||
});
|
||||
|
||||
const gruposFiltrados = computed(() => {
|
||||
const q = String(busca.value || '').trim().toLowerCase();
|
||||
if (!q) return grupos.value;
|
||||
return grupos.value.filter((g) => String(g.nome || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Dialog
|
||||
const dlgOpen = ref(false);
|
||||
const dlgMode = ref('create');
|
||||
const dlgForm = ref({ id: '', nome: '', cor: '#6366F1' });
|
||||
const dlgError = ref('');
|
||||
const PRESET_COLORS = ['6366f1', '8b5cf6', 'ec4899', 'ef4444', 'f97316', 'eab308', '22c55e', '14b8a6', '3b82f6', '06b6d4', '64748b', '292524'];
|
||||
|
||||
function abrirCriar() {
|
||||
dlgMode.value = 'create';
|
||||
dlgForm.value = { id: '', nome: '', cor: '#6366F1' };
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
function abrirEditar(row) {
|
||||
if (row.is_system) {
|
||||
toast.add({ severity: 'info', summary: 'Grupo do sistema', detail: 'Não dá pra editar grupos do sistema.', life: 2500 });
|
||||
return;
|
||||
}
|
||||
dlgMode.value = 'edit';
|
||||
dlgForm.value = {
|
||||
id: row.id,
|
||||
nome: row.nome || '',
|
||||
cor: row.cor ? (row.cor.startsWith('#') ? row.cor : '#' + row.cor) : '#6366F1'
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
const nome = String(dlgForm.value.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlgError.value = 'Informe um nome.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
dlgError.value = '';
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getTenantId();
|
||||
const cor = dlgForm.value.cor.startsWith('#') ? dlgForm.value.cor : '#' + dlgForm.value.cor;
|
||||
if (dlgMode.value === 'create') {
|
||||
const { error } = await supabase.from('patient_groups').insert({
|
||||
owner_id: ownerId, tenant_id: tenantId,
|
||||
nome, cor, is_system: false, is_active: true
|
||||
});
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Grupo criado', life: 2200 });
|
||||
} else {
|
||||
const { error } = await supabase.from('patient_groups')
|
||||
.update({ nome, cor })
|
||||
.eq('id', dlgForm.value.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Grupo atualizado', life: 2200 });
|
||||
}
|
||||
dlgOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
const msg = e?.message || '';
|
||||
dlgError.value = (e?.code === '23505' || /duplicate/i.test(msg)) ? 'Já existe um grupo com esse nome.' : (msg || 'Falha ao salvar.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmarExcluir(row) {
|
||||
if (row.is_system) return;
|
||||
confirm.require({
|
||||
message: `Excluir o grupo "${row.nome}"? Os pacientes serão desvinculados.`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: () => excluir(row)
|
||||
});
|
||||
}
|
||||
async function excluir(row) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await supabase.from('patient_group_patient').delete().eq('patient_group_id', row.id);
|
||||
const { error } = await supabase.from('patient_groups').delete().eq('id', row.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Grupo excluído', life: 2200 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
}
|
||||
load();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="mg-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
v-show="isMobile"
|
||||
aria-label="Estatísticas e busca"
|
||||
>
|
||||
<div id="mg-mobile-drawer-target" class="mg-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
<Transition name="mg-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="mg-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section class="mg-page">
|
||||
<header class="mg-page__head">
|
||||
<button
|
||||
class="mg-menu-btn mg-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Estatísticas & busca'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Menu Grupos</span>
|
||||
</button>
|
||||
<div class="mg-page__title">
|
||||
<i class="pi pi-th-large text-cyan-300" />
|
||||
<span>Grupos</span>
|
||||
<span class="mg-page__count">{{ gruposFiltrados.length }}</span>
|
||||
</div>
|
||||
<div class="mg-page__actions">
|
||||
<button
|
||||
class="mg-act-btn"
|
||||
v-tooltip.bottom="'Novo grupo'"
|
||||
:disabled="loading"
|
||||
@click="abrirCriar"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Novo</span>
|
||||
</button>
|
||||
<button
|
||||
class="mg-head-btn"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button class="mg-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mg-body">
|
||||
<Teleport to="#mg-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mg-side">
|
||||
<div class="mg-w">
|
||||
<div class="mg-w__head">
|
||||
<span class="mg-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||
</div>
|
||||
<div class="mg-stats">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 4" :key="`gsk-${i}`" class="mg-stat" aria-busy="true">
|
||||
<div class="mg-stat__val melissa-skeleton melissa-skeleton--number" />
|
||||
<div class="mg-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="s in stats"
|
||||
v-else
|
||||
:key="s.key"
|
||||
class="mg-stat"
|
||||
:class="`is-${s.cls}`"
|
||||
>
|
||||
<div class="mg-stat__val">{{ s.value }}</div>
|
||||
<div class="mg-stat__lbl">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mg-w">
|
||||
<div class="mg-w__head">
|
||||
<span class="mg-w__title"><i class="pi pi-search" /> Buscar</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="busca"
|
||||
type="text"
|
||||
placeholder="Nome do grupo…"
|
||||
class="mg-search__input"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<div class="mg-main">
|
||||
<div class="mg-list">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 5" :key="`gpsk-${i}`" class="mg-card mg-card--skeleton" aria-busy="true">
|
||||
<span class="mg-card__dot melissa-skeleton" style="border-radius: 50%;" />
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
|
||||
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="gruposFiltrados.length === 0" class="mg-empty">
|
||||
<i class="pi pi-th-large mg-empty__icon" />
|
||||
<div class="mg-empty__title">Nenhum grupo encontrado</div>
|
||||
<div class="mg-empty__hint">
|
||||
<template v-if="busca">Ajuste a busca pra ver mais resultados.</template>
|
||||
<template v-else>Crie seu primeiro grupo pra organizar pacientes.</template>
|
||||
</div>
|
||||
<button class="mg-act-btn" @click="abrirCriar">
|
||||
<i class="pi pi-plus" /><span>Novo grupo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="g in gruposFiltrados"
|
||||
v-else
|
||||
:key="g.id"
|
||||
class="mg-card"
|
||||
:class="{ 'is-system': g.is_system }"
|
||||
@click="abrirEditar(g)"
|
||||
>
|
||||
<span class="mg-card__dot" :style="{ background: g.cor || '#6366f1' }" />
|
||||
<div class="mg-card__main">
|
||||
<div class="mg-card__name-row">
|
||||
<span class="mg-card__name">{{ g.nome }}</span>
|
||||
<span v-if="g.is_system" class="mg-card__badge">Sistema</span>
|
||||
</div>
|
||||
<div class="mg-card__meta">
|
||||
<span><i class="pi pi-users" /> {{ g.pacientes_count }} {{ g.pacientes_count === 1 ? 'paciente' : 'pacientes' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mg-card__actions" @click.stop>
|
||||
<button
|
||||
v-if="!g.is_system"
|
||||
class="mg-card__btn"
|
||||
v-tooltip.left="'Editar'"
|
||||
@click="abrirEditar(g)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!g.is_system"
|
||||
class="mg-card__btn mg-card__btn--danger"
|
||||
v-tooltip.left="'Excluir'"
|
||||
@click="confirmarExcluir(g)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '380px', maxWidth: '92vw' }"
|
||||
:header="dlgMode === 'create' ? 'Novo grupo' : 'Editar grupo'"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Nome
|
||||
<InputText v-model="dlgForm.nome" placeholder="Ex: Adolescentes, Casais, Adultos…" class="w-full mt-1" autofocus @keydown.enter="salvar" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Cor
|
||||
<input v-model="dlgForm.cor" type="color" class="w-full h-9 mt-1 rounded-md border border-[var(--surface-border)] bg-transparent cursor-pointer" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="c in PRESET_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="w-6 h-6 rounded-full border border-white/20 transition-transform hover:scale-110"
|
||||
:style="{ background: '#' + c }"
|
||||
@click="dlgForm.cor = '#' + c"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dlgError" class="text-xs text-red-400">{{ dlgError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="dlgOpen = false" />
|
||||
<Button :label="saving ? 'Salvando…' : 'Salvar'" :loading="saving" :disabled="!dlgForm.nome.trim() || saving" @click="salvar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Mesmo CSS do MelissaTags trocando o prefixo mt- → mg- */
|
||||
.mg-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: mg-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes mg-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mg-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mg-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mg-page__title > span:not(.mg-page__count) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mg-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mg-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.mg-close, .mg-head-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mg-close:hover, .mg-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.mg-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
.mg-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mg-act-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mg-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.mg-menu-btn {
|
||||
display: none;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mg-menu-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mg-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mg-side {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mg-side::-webkit-scrollbar { width: 5px; }
|
||||
.mg-side::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mg-w {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mg-w__head { margin-bottom: 10px; }
|
||||
.mg-w__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mg-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
|
||||
|
||||
.mg-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mg-stat {
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.mg-stat__val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mg-stat__lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mg-stat.is-ok .mg-stat__val { color: rgb(74, 222, 128); }
|
||||
|
||||
.mg-search__input {
|
||||
width: 100%;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.mg-search__input:focus { border-color: var(--m-border-strong); }
|
||||
|
||||
.mg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.mg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-list::-webkit-scrollbar { width: 5px; }
|
||||
.mg-list::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mg-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mg-card:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mg-card.is-system { cursor: default; }
|
||||
.mg-card.is-system:hover { transform: none; }
|
||||
.mg-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
|
||||
.mg-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
|
||||
|
||||
.mg-card__dot {
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.mg-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mg-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-card__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mg-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mg-card__meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.mg-card__meta i { margin-right: 4px; }
|
||||
|
||||
.mg-card__actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.mg-card__btn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mg-card__btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
|
||||
.mg-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mg-card__btn > i { font-size: 0.7rem; }
|
||||
|
||||
.mg-empty {
|
||||
margin: 24px 0;
|
||||
padding: 56px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
border: 2px dashed var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-empty__icon {
|
||||
font-size: 2rem;
|
||||
color: var(--m-text-faint);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mg-empty__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mg-empty__hint {
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mg-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mg-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mg-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mg-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mg-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mg-mobile-drawer__scroll .mg-side {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.mg-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mg-drawer-fade-enter-active,
|
||||
.mg-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mg-drawer-fade-enter-from,
|
||||
.mg-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mg-body { flex-direction: column; padding: 8px; }
|
||||
.mg-main { width: 100%; }
|
||||
.mg-page__title > span:first-of-type { display: none; }
|
||||
.mg-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mg-act-btn span { display: none; }
|
||||
.mg-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
+1227
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,876 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaMedicos — CRUD de médicos/encaminhadores dentro de Melissa.
|
||||
* Segue blueprint melissa-page-blueprint.md.
|
||||
*
|
||||
* Layout 2-col:
|
||||
* - COL 1 — Aside (~280px): stats (total, com pacientes, especialidades) + busca
|
||||
* - COL 2 — Lista de cards (avatar com inicial, nome, especialidade, contato,
|
||||
* contagem de pacientes encaminhados)
|
||||
*
|
||||
* Click no card abre dialog de edição. Botão "+" cria novo.
|
||||
* Reusa Medicos.service.js (createMedico, updateMedico, deleteMedico).
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import {
|
||||
listMedicosWithPatientCounts,
|
||||
createMedico,
|
||||
updateMedico,
|
||||
deleteMedico
|
||||
} from '@/services/Medicos.service.js';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Breakpoints + drawer ───────────────────────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const medicos = ref([]);
|
||||
const busca = ref('');
|
||||
const carregandoInicial = computed(
|
||||
() => loading.value && medicos.value.length === 0
|
||||
);
|
||||
|
||||
// ── Especialidades ─────────────────────────────────────────
|
||||
const ESPECIALIDADES = [
|
||||
'Psiquiatria',
|
||||
'Neurologia',
|
||||
'Neuropsiquiatria infantil',
|
||||
'Clínica geral',
|
||||
'Pediatria',
|
||||
'Geriatria',
|
||||
'Endocrinologia',
|
||||
'Psicologia (encaminhador)',
|
||||
'Assistência social',
|
||||
'Fonoaudiologia',
|
||||
'Terapia ocupacional',
|
||||
'Fisioterapia',
|
||||
'Outra'
|
||||
];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
function digitsOnly(v) { return String(v ?? '').replace(/\D/g, ''); }
|
||||
function fmtPhone(v) {
|
||||
const d = digitsOnly(v);
|
||||
if (!d) return '';
|
||||
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`;
|
||||
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`;
|
||||
return d;
|
||||
}
|
||||
function iniciais(nome) {
|
||||
if (!nome) return '?';
|
||||
const partes = String(nome).trim().split(/\s+/);
|
||||
if (partes.length === 1) return partes[0][0]?.toUpperCase() || '?';
|
||||
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────
|
||||
const stats = computed(() => {
|
||||
const all = medicos.value;
|
||||
const comPacs = all.filter((m) => Number(m.patients_count || 0) > 0).length;
|
||||
const totalPacs = all.reduce((s, m) => s + Number(m.patients_count || 0), 0);
|
||||
const especs = new Set(all.map((m) => m.especialidade).filter(Boolean)).size;
|
||||
return [
|
||||
{ key: 'total', label: 'Médicos', value: all.length, cls: 'neutral' },
|
||||
{ key: 'esp', label: 'Especialidades', value: especs, cls: 'neutral' },
|
||||
{ key: 'com', label: 'Com pacientes', value: comPacs, cls: comPacs > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'enc', label: 'Encaminhados', value: totalPacs, cls: totalPacs > 0 ? 'ok' : 'neutral' }
|
||||
];
|
||||
});
|
||||
|
||||
const medicosFiltrados = computed(() => {
|
||||
const q = String(busca.value || '').trim().toLowerCase();
|
||||
if (!q) return medicos.value;
|
||||
return medicos.value.filter((m) => {
|
||||
return String(m.nome || '').toLowerCase().includes(q) ||
|
||||
String(m.especialidade || '').toLowerCase().includes(q) ||
|
||||
String(m.crm || '').toLowerCase().includes(q) ||
|
||||
String(m.clinica || '').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listMedicosWithPatientCounts();
|
||||
medicos.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e?.message, life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog ─────────────────────────────────────────────────
|
||||
const dlgOpen = ref(false);
|
||||
const dlgMode = ref('create');
|
||||
const dlgError = ref('');
|
||||
const dlgForm = ref({
|
||||
id: '',
|
||||
nome: '',
|
||||
crm: '',
|
||||
especialidade: '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: '',
|
||||
telefone_pessoal: '',
|
||||
email: '',
|
||||
clinica: '',
|
||||
cidade: '',
|
||||
estado: 'SP',
|
||||
observacoes: ''
|
||||
});
|
||||
|
||||
const especialidadeFinal = computed(() => {
|
||||
if (dlgForm.value.especialidade === 'Outra') return dlgForm.value.especialidade_outra.trim();
|
||||
return dlgForm.value.especialidade;
|
||||
});
|
||||
|
||||
function abrirCriar() {
|
||||
dlgMode.value = 'create';
|
||||
dlgForm.value = {
|
||||
id: '', nome: '', crm: '', especialidade: '', especialidade_outra: '',
|
||||
telefone_profissional: '', telefone_pessoal: '', email: '',
|
||||
clinica: '', cidade: '', estado: 'SP', observacoes: ''
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
function abrirEditar(row) {
|
||||
dlgMode.value = 'edit';
|
||||
const isOutraEsp = row.especialidade && !ESPECIALIDADES.includes(row.especialidade);
|
||||
dlgForm.value = {
|
||||
id: row.id,
|
||||
nome: row.nome || '',
|
||||
crm: row.crm || '',
|
||||
especialidade: isOutraEsp ? 'Outra' : (row.especialidade || ''),
|
||||
especialidade_outra: isOutraEsp ? row.especialidade : '',
|
||||
telefone_profissional: fmtPhone(row.telefone_profissional),
|
||||
telefone_pessoal: fmtPhone(row.telefone_pessoal),
|
||||
email: row.email || '',
|
||||
clinica: row.clinica || '',
|
||||
cidade: row.cidade || '',
|
||||
estado: row.estado || 'SP',
|
||||
observacoes: row.observacoes || ''
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
const nome = String(dlgForm.value.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlgError.value = 'Informe o nome do médico.';
|
||||
return;
|
||||
}
|
||||
if (dlgForm.value.especialidade === 'Outra' && !dlgForm.value.especialidade_outra.trim()) {
|
||||
dlgError.value = 'Informe a especialidade.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
dlgError.value = '';
|
||||
|
||||
const payload = {
|
||||
nome,
|
||||
crm: dlgForm.value.crm.trim() || null,
|
||||
especialidade: especialidadeFinal.value || null,
|
||||
telefone_profissional: dlgForm.value.telefone_profissional ? digitsOnly(dlgForm.value.telefone_profissional) : null,
|
||||
telefone_pessoal: dlgForm.value.telefone_pessoal ? digitsOnly(dlgForm.value.telefone_pessoal) : null,
|
||||
email: dlgForm.value.email.trim() || null,
|
||||
clinica: dlgForm.value.clinica.trim() || null,
|
||||
cidade: dlgForm.value.cidade.trim() || null,
|
||||
estado: dlgForm.value.estado.trim() || null,
|
||||
observacoes: dlgForm.value.observacoes.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (dlgMode.value === 'create') {
|
||||
await createMedico(payload);
|
||||
toast.add({ severity: 'success', summary: 'Médico cadastrado', life: 2200 });
|
||||
} else {
|
||||
await updateMedico(dlgForm.value.id, payload);
|
||||
toast.add({ severity: 'success', summary: 'Médico atualizado', life: 2200 });
|
||||
}
|
||||
dlgOpen.value = false;
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
dlgError.value = e?.message || 'Falha ao salvar.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────
|
||||
function confirmarExcluir(row) {
|
||||
confirm.require({
|
||||
message: `Desativar "Dr(a). ${row.nome}"? O registro será ocultado da listagem.`,
|
||||
header: 'Desativar médico',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: () => excluir(row)
|
||||
});
|
||||
}
|
||||
async function excluir(row) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await deleteMedico(row.id);
|
||||
toast.add({ severity: 'success', summary: 'Médico desativado', life: 2200 });
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao desativar.', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
}
|
||||
await fetchAll();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="mm-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
v-show="isMobile"
|
||||
aria-label="Estatísticas e busca"
|
||||
>
|
||||
<div id="mm-mobile-drawer-target" class="mm-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
<Transition name="mm-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="mm-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section class="mm-page">
|
||||
<header class="mm-page__head">
|
||||
<button
|
||||
class="mm-menu-btn mm-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Estatísticas & busca'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Menu Médicos</span>
|
||||
</button>
|
||||
<div class="mm-page__title">
|
||||
<i class="pi pi-user-edit text-rose-300" />
|
||||
<span>Médicos & referências</span>
|
||||
<span class="mm-page__count">{{ medicosFiltrados.length }}</span>
|
||||
</div>
|
||||
<div class="mm-page__actions">
|
||||
<button
|
||||
class="mm-act-btn"
|
||||
v-tooltip.bottom="'Novo médico'"
|
||||
:disabled="loading"
|
||||
@click="abrirCriar"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Novo</span>
|
||||
</button>
|
||||
<button
|
||||
class="mm-head-btn"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
:disabled="loading"
|
||||
@click="fetchAll"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button class="mm-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mm-body">
|
||||
<Teleport to="#mm-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mm-side">
|
||||
<div class="mm-w">
|
||||
<div class="mm-w__head">
|
||||
<span class="mm-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||
</div>
|
||||
<div class="mm-stats">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 4" :key="`sk-${i}`" class="mm-stat" aria-busy="true">
|
||||
<div class="mm-stat__val melissa-skeleton melissa-skeleton--number" />
|
||||
<div class="mm-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="s in stats"
|
||||
v-else
|
||||
:key="s.key"
|
||||
class="mm-stat"
|
||||
:class="`is-${s.cls}`"
|
||||
>
|
||||
<div class="mm-stat__val">{{ s.value }}</div>
|
||||
<div class="mm-stat__lbl">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mm-w">
|
||||
<div class="mm-w__head">
|
||||
<span class="mm-w__title"><i class="pi pi-search" /> Buscar</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="busca"
|
||||
type="text"
|
||||
placeholder="Nome, especialidade, CRM, clínica…"
|
||||
class="mm-search__input"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<div class="mm-main">
|
||||
<div class="mm-list">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 5" :key="`csk-${i}`" class="mm-card mm-card--skeleton" aria-busy="true">
|
||||
<span class="mm-card__avatar melissa-skeleton melissa-skeleton--avatar" />
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
|
||||
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
|
||||
<span class="melissa-skeleton melissa-skeleton--text" style="width: 60%;" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="medicosFiltrados.length === 0" class="mm-empty">
|
||||
<i class="pi pi-user-edit mm-empty__icon" />
|
||||
<div class="mm-empty__title">Nenhum médico encontrado</div>
|
||||
<div class="mm-empty__hint">
|
||||
<template v-if="busca">Ajuste a busca pra ver mais resultados.</template>
|
||||
<template v-else>Cadastre médicos pra registrar encaminhamentos.</template>
|
||||
</div>
|
||||
<button class="mm-act-btn" @click="abrirCriar">
|
||||
<i class="pi pi-plus" /><span>Novo médico</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="m in medicosFiltrados"
|
||||
v-else
|
||||
:key="m.id"
|
||||
class="mm-card"
|
||||
@click="abrirEditar(m)"
|
||||
>
|
||||
<span class="mm-card__avatar">{{ iniciais(m.nome) }}</span>
|
||||
<div class="mm-card__main">
|
||||
<div class="mm-card__name-row">
|
||||
<span class="mm-card__name">Dr(a). {{ m.nome }}</span>
|
||||
<span v-if="m.especialidade" class="mm-card__esp">{{ m.especialidade }}</span>
|
||||
</div>
|
||||
<div class="mm-card__meta">
|
||||
<span v-if="m.crm"><i class="pi pi-id-card" /> CRM {{ m.crm }}</span>
|
||||
<span v-if="m.telefone_profissional"><i class="pi pi-phone" /> {{ fmtPhone(m.telefone_profissional) }}</span>
|
||||
<span v-if="m.email"><i class="pi pi-envelope" /> {{ m.email }}</span>
|
||||
<span v-if="m.clinica"><i class="pi pi-building" /> {{ m.clinica }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-card__right">
|
||||
<div class="mm-card__count">
|
||||
<i class="pi pi-users" />
|
||||
{{ m.patients_count || 0 }}
|
||||
</div>
|
||||
<div class="mm-card__actions" @click.stop>
|
||||
<button
|
||||
class="mm-card__btn"
|
||||
v-tooltip.left="'Editar'"
|
||||
@click="abrirEditar(m)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
class="mm-card__btn mm-card__btn--danger"
|
||||
v-tooltip.left="'Desativar'"
|
||||
@click="confirmarExcluir(m)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog create/edit -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '560px', maxWidth: '94vw' }"
|
||||
:header="dlgMode === 'create' ? 'Novo médico' : 'Editar médico'"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Nome *
|
||||
<InputText v-model="dlgForm.nome" placeholder="Nome completo" class="w-full mt-1" autofocus />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
CRM
|
||||
<InputText v-model="dlgForm.crm" placeholder="Ex: 123456-SP" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Especialidade
|
||||
<Select v-model="dlgForm.especialidade" :options="ESPECIALIDADES" placeholder="Selecione…" class="w-full mt-1" />
|
||||
</label>
|
||||
<label v-if="dlgForm.especialidade === 'Outra'" class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Especialidade (livre)
|
||||
<InputText v-model="dlgForm.especialidade_outra" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Telefone profissional
|
||||
<InputText v-model="dlgForm.telefone_profissional" placeholder="(11) 91234-5678" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Telefone pessoal
|
||||
<InputText v-model="dlgForm.telefone_pessoal" placeholder="(11) 91234-5678" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
E-mail
|
||||
<InputText v-model="dlgForm.email" placeholder="email@dominio.com" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Clínica/instituição
|
||||
<InputText v-model="dlgForm.clinica" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Cidade
|
||||
<InputText v-model="dlgForm.cidade" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Estado
|
||||
<InputText v-model="dlgForm.estado" maxlength="2" class="w-full mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] md:col-span-2">
|
||||
Observações
|
||||
<Textarea v-model="dlgForm.observacoes" autoResize rows="2" class="w-full mt-1" />
|
||||
</label>
|
||||
<div v-if="dlgError" class="text-xs text-red-400 md:col-span-2">{{ dlgError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="dlgOpen = false" />
|
||||
<Button :label="saving ? 'Salvando…' : 'Salvar'" :loading="saving" :disabled="!dlgForm.nome.trim() || saving" @click="salvar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mm-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: mm-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes mm-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mm-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mm-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mm-page__title > span:not(.mm-page__count) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mm-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mm-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.mm-close, .mm-head-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mm-close:hover, .mm-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.mm-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
.mm-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mm-act-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mm-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.mm-menu-btn {
|
||||
display: none;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mm-menu-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mm-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mm-side {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mm-side::-webkit-scrollbar { width: 5px; }
|
||||
.mm-side::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mm-w {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mm-w__head { margin-bottom: 10px; }
|
||||
.mm-w__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mm-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
|
||||
|
||||
.mm-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mm-stat {
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.mm-stat__val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mm-stat__lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mm-stat.is-ok .mm-stat__val { color: rgb(74, 222, 128); }
|
||||
|
||||
.mm-search__input {
|
||||
width: 100%;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.mm-search__input:focus { border-color: var(--m-border-strong); }
|
||||
|
||||
.mm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.mm-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mm-list::-webkit-scrollbar { width: 5px; }
|
||||
.mm-list::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mm-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mm-card:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mm-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
|
||||
.mm-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
|
||||
|
||||
.mm-card__avatar {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--m-accent-strong);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
display: grid; place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mm-card__main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.mm-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mm-card__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mm-card__esp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, var(--m-border));
|
||||
color: var(--m-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mm-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
}
|
||||
.mm-card__meta i { margin-right: 4px; font-size: 0.65rem; }
|
||||
|
||||
.mm-card__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mm-card__count {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.mm-card__count i { font-size: 0.65rem; }
|
||||
|
||||
.mm-card__actions { display: flex; gap: 4px; }
|
||||
.mm-card__btn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mm-card__btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
|
||||
.mm-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mm-card__btn > i { font-size: 0.7rem; }
|
||||
|
||||
.mm-empty {
|
||||
margin: 24px 0;
|
||||
padding: 56px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
border: 2px dashed var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||
gap: 8px;
|
||||
}
|
||||
.mm-empty__icon { font-size: 2rem; color: var(--m-text-faint); margin-bottom: 4px; }
|
||||
.mm-empty__title { font-size: 0.92rem; font-weight: 600; color: var(--m-text); }
|
||||
.mm-empty__hint { font-size: 0.78rem; margin-bottom: 8px; }
|
||||
|
||||
/* Drawer mobile */
|
||||
.mm-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mm-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mm-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mm-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mm-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mm-mobile-drawer__scroll .mm-side {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.mm-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mm-drawer-fade-enter-active,
|
||||
.mm-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mm-drawer-fade-enter-from,
|
||||
.mm-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mm-body { flex-direction: column; padding: 8px; }
|
||||
.mm-main { width: 100%; }
|
||||
.mm-page__title > span:first-of-type { display: none; }
|
||||
.mm-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mm-act-btn span { display: none; }
|
||||
.mm-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
.mm-card__count { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -54,6 +54,7 @@ const CATEGORIAS = [
|
||||
{ key: 'agenda', label: 'Minha Agenda', icon: 'pi pi-calendar' },
|
||||
{ key: 'pacientes', label: 'Meus Pacientes', icon: 'pi pi-users' },
|
||||
{ key: 'cadastros-recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox' },
|
||||
{ key: 'agendamentos-recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-bell' },
|
||||
{ key: 'meu-link-cadastro', label: 'Meu link de cadastro', icon: 'pi pi-link', tipo: 'link-cadastro' }
|
||||
]
|
||||
},
|
||||
@@ -64,7 +65,9 @@ const CATEGORIAS = [
|
||||
{ key: 'compromissos', label: 'Compromissos determinados', icon: 'pi pi-flag' },
|
||||
{ key: 'grupos', label: 'Grupos de pacientes', icon: 'pi pi-th-large' },
|
||||
{ key: 'tags', label: 'Tags', icon: 'pi pi-tag' },
|
||||
{ key: 'medicos', label: 'Médicos e referências', icon: 'pi pi-user-edit' }
|
||||
{ key: 'medicos', label: 'Médicos e referências', icon: 'pi pi-user-edit' },
|
||||
{ key: 'online-scheduling', label: 'Agendador online', icon: 'pi pi-calendar-clock' },
|
||||
{ key: 'link-externo', label: 'Link externo de cadastro', icon: 'pi pi-share-alt' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -74,28 +77,98 @@ const CATEGORIAS = [
|
||||
label: 'WhatsApp',
|
||||
icon: 'pi pi-whatsapp',
|
||||
color: '#22c55e',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Atendimento',
|
||||
items: [
|
||||
{ key: 'conversas', label: 'Conversas', icon: 'pi pi-comments' },
|
||||
{ key: 'notificacoes', label: 'Notificações enviadas', icon: 'pi pi-bell' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuração',
|
||||
items: [
|
||||
{ key: 'wa-canal', label: 'Configurar canal', icon: 'pi pi-cog', route: { name: 'ConfiguracoesWhatsapp' } },
|
||||
{ key: 'wa-templates', label: 'Templates de mensagem', icon: 'pi pi-file-edit', route: { name: 'ConfiguracoesWhatsappTemplates' } },
|
||||
{ key: 'wa-creditos', label: 'Créditos', icon: 'pi pi-credit-card', route: { name: 'ConfiguracoesCreditosWhatsapp' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'prontuarios',
|
||||
label: 'Prontuários',
|
||||
icon: 'pi pi-file',
|
||||
color: '#0ea5e9',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Acesso',
|
||||
items: [
|
||||
// Sem route — emit('select', 'pacientes') aciona o MelissaPacientes
|
||||
// (lá o duplo-click no card abre PatientProntuario). Mantém o
|
||||
// user dentro do Melissa em vez de jogar pra rota externa.
|
||||
{ key: 'pacientes', label: 'Abrir por paciente', icon: 'pi pi-users' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Documentos',
|
||||
items: [
|
||||
{ key: 'documentos', label: 'Documentos', icon: 'pi pi-file' },
|
||||
{ key: 'documentos-templates', label: 'Templates de documentos', icon: 'pi pi-file-edit' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'financeiro',
|
||||
label: 'Financeiro',
|
||||
icon: 'pi pi-wallet',
|
||||
color: '#f59e0b',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Principais',
|
||||
items: [
|
||||
// Sem route — abre embedado via MelissaEmbed dentro do overlay Melissa
|
||||
{ key: 'financeiro', label: 'Visão geral', icon: 'pi pi-chart-line' },
|
||||
{ key: 'financeiro-lancamentos', label: 'Lançamentos', icon: 'pi pi-list' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Análise',
|
||||
items: [
|
||||
{ key: 'relatorios', label: 'Relatórios', icon: 'pi pi-chart-bar' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'configuracoes',
|
||||
label: 'Configurações',
|
||||
icon: 'pi pi-cog',
|
||||
color: '#94a3b8',
|
||||
groups: []
|
||||
groups: [
|
||||
{
|
||||
title: 'Layout Melissa',
|
||||
items: [
|
||||
// Sem `route` — emit('select', 'aparencia') abre página interna do Melissa
|
||||
{ key: 'aparencia', label: 'Aparência e cronômetro', icon: 'pi pi-palette' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Agenda',
|
||||
items: [
|
||||
{ key: 'cfg-agenda', label: 'Agenda', icon: 'pi pi-calendar', route: { name: 'ConfiguracoesAgenda' } },
|
||||
{ key: 'cfg-agendador', label: 'Agendador externo', icon: 'pi pi-link', route: { name: 'ConfiguracoesAgendador' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'WhatsApp',
|
||||
items: [
|
||||
{ key: 'cfg-wa', label: 'Canal de WhatsApp', icon: 'pi pi-whatsapp', route: { name: 'ConfiguracoesWhatsapp' } },
|
||||
{ key: 'cfg-wa-templates', label: 'Templates', icon: 'pi pi-file-edit', route: { name: 'ConfiguracoesWhatsappTemplates' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -103,6 +176,11 @@ const CATEGORIAS = [
|
||||
const selectedKey = ref(CATEGORIAS[0].key); // primeira categoria por default
|
||||
const copiado = ref(false);
|
||||
|
||||
// Drill-down mobile: false = lista de categorias, true = sub-itens da
|
||||
// categoria escolhida. CSS controla visibilidade via translateX em <lg.
|
||||
// Em desktop o flag é ignorado (ambas colunas sempre visíveis).
|
||||
const mobileSubView = ref(false);
|
||||
|
||||
const categoriaAtiva = computed(() =>
|
||||
CATEGORIAS.find((c) => c.key === selectedKey.value) || CATEGORIAS[0]
|
||||
);
|
||||
@@ -115,10 +193,23 @@ function selecionarCategoria(key) {
|
||||
selectedKey.value = key;
|
||||
copiado.value = false;
|
||||
themeViewActive.value = false; // sai do tema ao mudar categoria
|
||||
mobileSubView.value = true; // drill-down em mobile
|
||||
}
|
||||
|
||||
function voltarParaCategorias() {
|
||||
mobileSubView.value = false;
|
||||
themeViewActive.value = false;
|
||||
}
|
||||
|
||||
function clicarSubItem(item) {
|
||||
if (item.tipo === 'link-cadastro') return; // inline, não navega
|
||||
// Se item tem route definida, navega direto (rota externa ao Melissa).
|
||||
// Senão, emite 'select' pro pai decidir (seções internas ao MelissaLayout).
|
||||
if (item.route) {
|
||||
emit('close');
|
||||
safePush(item.route);
|
||||
return;
|
||||
}
|
||||
emit('select', item.key);
|
||||
}
|
||||
|
||||
@@ -172,16 +263,13 @@ function navAndClose(target, fallback) {
|
||||
safePush(target, fallback);
|
||||
}
|
||||
|
||||
function goPerfil() { navAndClose({ name: 'account-profile' }, '/account/profile'); }
|
||||
function goSeguranca() { navAndClose({ name: 'account-security' }, '/account/security'); }
|
||||
function goPlano() {
|
||||
const r = role.value || sessionRole.value;
|
||||
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') {
|
||||
return navAndClose({ name: 'admin-meu-plano' }, '/admin/meu-plano');
|
||||
}
|
||||
if (r === 'supervisor') return navAndClose({ name: 'supervisor.meu-plano' }, '/supervisor/meu-plano');
|
||||
return navAndClose({ name: 'therapist-meu-plano' }, '/therapist/meu-plano');
|
||||
}
|
||||
// Atalhos de Conta — abrem embedados dentro do MelissaConfiguracoes
|
||||
// (em vez de navegar pra rota externa). Cada um vira uma section pré-
|
||||
// selecionada na sidebar de configs.
|
||||
function goPerfil() { emit('select', 'perfil'); emit('close'); }
|
||||
function goPlano() { emit('select', 'plano'); emit('close'); }
|
||||
function goNegocio() { emit('select', 'negocio'); emit('close'); }
|
||||
function goSeguranca() { emit('select', 'seguranca'); emit('close'); }
|
||||
|
||||
async function toggleDarkAndPersist() {
|
||||
try {
|
||||
@@ -203,6 +291,7 @@ const themeViewActive = ref(false);
|
||||
|
||||
function toggleThemeView() {
|
||||
themeViewActive.value = !themeViewActive.value;
|
||||
if (themeViewActive.value) mobileSubView.value = true; // drill-down em mobile
|
||||
}
|
||||
|
||||
function saveThemeToStorage() {
|
||||
@@ -252,11 +341,22 @@ async function sair() {
|
||||
|
||||
<template>
|
||||
<div class="mm-layer" @click.self="emit('close')">
|
||||
<div class="mm-panel">
|
||||
<div class="mm-panel" :class="{ 'is-mobile-sub': mobileSubView }">
|
||||
<!-- ════ ESQUERDA: categorias ════ -->
|
||||
<nav class="mm-side">
|
||||
<div class="mm-side__head">
|
||||
<div class="mm-side__title">Menu</div>
|
||||
<!-- Fechar (mobile only): em desktop o ψ continua visível
|
||||
no canto inferior pra fechar; em mobile o menu cobre
|
||||
tudo, então precisa de botão dedicado. -->
|
||||
<button
|
||||
class="mm-side__close mm-side__close--mobile-only"
|
||||
title="Fechar menu"
|
||||
aria-label="Fechar menu"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mm-side__list">
|
||||
@@ -291,6 +391,9 @@ async function sair() {
|
||||
<button class="mm-foot-item" @click="goPlano">
|
||||
<i class="pi pi-credit-card" /><span>Meus Planos</span>
|
||||
</button>
|
||||
<button class="mm-foot-item" @click="goNegocio">
|
||||
<i class="pi pi-briefcase" /><span>Meu Negócio</span>
|
||||
</button>
|
||||
<button class="mm-foot-item" @click="goSeguranca">
|
||||
<i class="pi pi-shield" /><span>Segurança</span>
|
||||
</button>
|
||||
@@ -328,6 +431,17 @@ async function sair() {
|
||||
<!-- ════ DIREITA: sub-itens OU cores do tema ════ -->
|
||||
<aside class="mm-aside">
|
||||
<div class="mm-aside__head">
|
||||
<!-- Voltar (mobile only): só aparece em <lg quando o
|
||||
drill-down está em modo "sub-itens". Em desktop as
|
||||
duas colunas convivem, voltar não faz sentido. -->
|
||||
<button
|
||||
class="mm-aside__back mm-aside__back--mobile-only"
|
||||
title="Voltar"
|
||||
aria-label="Voltar pra categorias"
|
||||
@click="voltarParaCategorias"
|
||||
>
|
||||
<i class="pi pi-arrow-left" />
|
||||
</button>
|
||||
<div class="mm-aside__title">
|
||||
{{ themeViewActive ? 'Cores do Tema' : categoriaAtiva.label }}
|
||||
</div>
|
||||
@@ -490,11 +604,18 @@ async function sair() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Layer (overlay full-screen, transparente) ───────────── */
|
||||
/* ─── Layer (overlay full-screen com blur sutil) ─────────────
|
||||
Aplica um leve escurecimento + blur-xs (2px) atrás do menu pra dar
|
||||
sensação de "modal" e desfocar o conteúdo embaixo. Em mobile (<lg)
|
||||
o media query mais embaixo aumenta a intensidade pra cobrir todo
|
||||
o viewport com força. */
|
||||
.mm-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* ─── Painel float ───────────────────────────────────────── */
|
||||
@@ -525,7 +646,13 @@ async function sair() {
|
||||
border-right: 1px solid var(--m-border);
|
||||
background: var(--m-bg-soft);
|
||||
}
|
||||
.mm-side__head { padding: 18px 18px 8px; }
|
||||
.mm-side__head {
|
||||
padding: 18px 18px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.mm-side__title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -533,6 +660,22 @@ async function sair() {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Botão fechar — só visível em mobile (≤lg). Vira display:flex no @media. */
|
||||
.mm-side__close {
|
||||
display: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mm-side__close:hover { background: var(--m-bg-soft-hover); }
|
||||
.mm-side__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -724,12 +867,39 @@ async function sair() {
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
}
|
||||
.mm-aside__head { margin-bottom: 14px; }
|
||||
.mm-aside__head {
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.mm-aside__title {
|
||||
color: var(--m-text);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Botão voltar — só visível em mobile (≤lg) com drill-down ativo. */
|
||||
.mm-aside__back {
|
||||
display: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mm-aside__back:hover { background: var(--m-bg-soft-hover); transform: translateX(-1px); }
|
||||
.mm-aside__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1039,4 +1209,108 @@ async function sair() {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Responsivo <lg (≤1023px) — drawer da esquerda (paridade com Agenda)
|
||||
───────────────────────────────────────────────────────────────
|
||||
- .mm-layer vira backdrop fullscreen (escurece + blur), click fora fecha
|
||||
- .mm-panel vira drawer 360px (mesmo tamanho do .ma-mobile-drawer),
|
||||
desliza da esquerda
|
||||
- .mm-side e .mm-aside viram camadas absolutas, alternam via
|
||||
translateX controlado pelo modificador .is-mobile-sub
|
||||
- Botão "fechar" no header da side, "voltar" no header do aside
|
||||
- z-index do .mm-layer sobe pra 90 pra cobrir o ψ (70) e o dock (65)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1023px) {
|
||||
/* Layer = backdrop. Click fora (no próprio layer) fecha via @click.self
|
||||
que já existe no template. position:fixed garante cobertura mesmo
|
||||
se algum ancestor estiver scrollado. */
|
||||
.mm-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
.mm-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: auto;
|
||||
width: min(360px, 88vw); /* paridade com .ma-mobile-drawer */
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--m-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* As duas colunas viram camadas full do painel, animadas via translateX. */
|
||||
.mm-side,
|
||||
.mm-aside {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.mm-side {
|
||||
transform: translateX(0);
|
||||
z-index: 1;
|
||||
border-right: none;
|
||||
}
|
||||
.mm-aside {
|
||||
transform: translateX(100%);
|
||||
z-index: 2;
|
||||
background: var(--m-bg-medium);
|
||||
}
|
||||
|
||||
/* Modo "sub-itens" (drill-down ativo) */
|
||||
.mm-panel.is-mobile-sub .mm-side {
|
||||
transform: translateX(-12%); /* leve parallax pra dar profundidade */
|
||||
}
|
||||
.mm-panel.is-mobile-sub .mm-aside {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Botões mobile-only ganham display */
|
||||
.mm-side__close--mobile-only { display: inline-flex; }
|
||||
.mm-aside__back--mobile-only { display: inline-flex; }
|
||||
|
||||
/* Header da side fica um pouco mais aberto pra acomodar o close */
|
||||
.mm-side__head {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.mm-side__title {
|
||||
font-size: 0.7rem; /* lê melhor em mobile */
|
||||
}
|
||||
|
||||
/* Aside head: o título fica MAIS espaçado no topo, e o aside ganha
|
||||
padding lateral menor (telas pequenas precisam de cada pixel). */
|
||||
.mm-aside {
|
||||
padding: 14px 14px 18px;
|
||||
}
|
||||
.mm-aside__head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Sub-itens com mais respiro vertical (toque tem que pegar) */
|
||||
.mm-sub {
|
||||
padding: 12px 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.mm-cat {
|
||||
padding: 12px 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.mm-cat__icon { width: 36px; height: 36px; }
|
||||
|
||||
/* Footer continua na tela 1 (lista de categorias) */
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,806 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaTags — CRUD de tags de pacientes dentro de Melissa.
|
||||
* Segue blueprint melissa-page-blueprint.md.
|
||||
*
|
||||
* Layout 2-col:
|
||||
* - COL 1 — Aside (~280px): stats (total, em uso, padrão, minhas) + busca
|
||||
* - COL 2 — Lista central: cards de tag (dot colorido + nome + contagem)
|
||||
*
|
||||
* Click num card abre dialog de edição. Botão "+" cria nova.
|
||||
* Reutiliza lógica de patient_tags da TagsPage (mais enxuta — sem multi-select).
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
// Dialog/InputText/Button auto-imported via PrimeVueResolver
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Breakpoints + drawer (blueprint §2/§3) ─────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const tags = ref([]);
|
||||
const busca = ref('');
|
||||
const carregandoInicial = computed(
|
||||
() => loading.value && tags.value.length === 0
|
||||
);
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────
|
||||
async function getOwnerId() {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data?.user?.id) throw new Error('Sessão não inicializada.');
|
||||
return data.user.id;
|
||||
}
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data } = await supabase
|
||||
.from('tenant_members').select('tenant_id')
|
||||
.eq('user_id', uid).eq('status', 'active')
|
||||
.order('created_at', { ascending: false }).limit(1).single();
|
||||
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
|
||||
return data.tenant_id;
|
||||
}
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────
|
||||
function normalize(r) {
|
||||
return {
|
||||
...r,
|
||||
nome: r?.nome ?? r?.name ?? '',
|
||||
cor: r?.cor ?? r?.color ?? null,
|
||||
is_padrao: Boolean(r?.is_padrao ?? r?.is_native ?? false),
|
||||
pacientes_count: Number(r?.pacientes_count ?? r?.patient_count ?? 0)
|
||||
};
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const v = await supabase.from('v_tag_patient_counts').select('*').eq('owner_id', ownerId).order('nome', { ascending: true });
|
||||
if (!v.error) {
|
||||
tags.value = (v.data || []).map(normalize);
|
||||
return;
|
||||
}
|
||||
const t = await supabase.from('patient_tags').select('*').eq('owner_id', ownerId);
|
||||
if (t.error) throw t.error;
|
||||
tags.value = (t.data || []).map(normalize).sort((a, b) => a.nome.localeCompare(b.nome));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar tags', detail: e?.message, life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────
|
||||
const stats = computed(() => {
|
||||
const all = tags.value;
|
||||
const padrao = all.filter((t) => t.is_padrao).length;
|
||||
const minhas = all.filter((t) => !t.is_padrao).length;
|
||||
const emUso = all.filter((t) => t.pacientes_count > 0).length;
|
||||
const totalPac = all.reduce((s, t) => s + Number(t.pacientes_count || 0), 0);
|
||||
return [
|
||||
{ key: 'total', label: 'Total', value: all.length, cls: 'neutral' },
|
||||
{ key: 'minhas', label: 'Minhas', value: minhas, cls: minhas > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'uso', label: 'Em uso', value: emUso, cls: emUso > 0 ? 'ok' : 'neutral' },
|
||||
{ key: 'pacientes', label: 'Pacientes', value: totalPac, cls: 'neutral' }
|
||||
];
|
||||
});
|
||||
|
||||
// ── Filtro ─────────────────────────────────────────────────
|
||||
const tagsFiltradas = computed(() => {
|
||||
const q = String(busca.value || '').trim().toLowerCase();
|
||||
if (!q) return tags.value;
|
||||
return tags.value.filter((t) => String(t.nome || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// ── Dialog create/edit ─────────────────────────────────────
|
||||
const dlgOpen = ref(false);
|
||||
const dlgMode = ref('create');
|
||||
const dlgForm = ref({ id: '', nome: '', cor: '#22C55E' });
|
||||
const dlgError = ref('');
|
||||
|
||||
const PRESET_COLORS = ['6366f1', '8b5cf6', 'ec4899', 'ef4444', 'f97316', 'eab308', '22c55e', '14b8a6', '3b82f6', '06b6d4', '64748b', '292524'];
|
||||
|
||||
function abrirCriar() {
|
||||
dlgMode.value = 'create';
|
||||
dlgForm.value = { id: '', nome: '', cor: '#22C55E' };
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
function abrirEditar(row) {
|
||||
if (row.is_padrao) {
|
||||
toast.add({ severity: 'info', summary: 'Tag padrão', detail: 'Não dá pra editar tags do sistema.', life: 2500 });
|
||||
return;
|
||||
}
|
||||
dlgMode.value = 'edit';
|
||||
dlgForm.value = {
|
||||
id: row.id,
|
||||
nome: row.nome || '',
|
||||
cor: row.cor ? (row.cor.startsWith('#') ? row.cor : '#' + row.cor) : '#22C55E'
|
||||
};
|
||||
dlgError.value = '';
|
||||
dlgOpen.value = true;
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
const nome = String(dlgForm.value.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlgError.value = 'Informe um nome.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
dlgError.value = '';
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const cor = dlgForm.value.cor.startsWith('#') ? dlgForm.value.cor : '#' + dlgForm.value.cor;
|
||||
if (dlgMode.value === 'create') {
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
const { error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, tenant_id: tenantId, nome, cor });
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Tag criada', life: 2200 });
|
||||
} else {
|
||||
const { error } = await supabase.from('patient_tags').update({ nome, cor, updated_at: new Date().toISOString() }).eq('id', dlgForm.value.id).eq('owner_id', ownerId);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Tag atualizada', life: 2200 });
|
||||
}
|
||||
dlgOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
const msg = e?.message || '';
|
||||
dlgError.value = (e?.code === '23505' || /duplicate/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao salvar.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────
|
||||
function confirmarExcluir(row) {
|
||||
if (row.is_padrao) return;
|
||||
confirm.require({
|
||||
message: `Excluir a tag "${row.nome}"? Os vínculos com pacientes também serão removidos.`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: () => excluir(row)
|
||||
});
|
||||
}
|
||||
async function excluir(row) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
await supabase.from('patient_patient_tag').delete().eq('tag_id', row.id);
|
||||
const { error } = await supabase.from('patient_tags').delete().eq('id', row.id).eq('owner_id', ownerId);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Tag excluída', life: 2200 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqMobileChange);
|
||||
}
|
||||
load();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="mt-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
v-show="isMobile"
|
||||
aria-label="Estatísticas e busca"
|
||||
>
|
||||
<div id="mt-mobile-drawer-target" class="mt-mobile-drawer__scroll" />
|
||||
</aside>
|
||||
<Transition name="mt-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="mt-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section class="mt-page">
|
||||
<header class="mt-page__head">
|
||||
<button
|
||||
class="mt-menu-btn mt-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Estatísticas & busca'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Menu Tags</span>
|
||||
</button>
|
||||
<div class="mt-page__title">
|
||||
<i class="pi pi-tag text-purple-300" />
|
||||
<span>Tags</span>
|
||||
<span class="mt-page__count">{{ tagsFiltradas.length }}</span>
|
||||
</div>
|
||||
<div class="mt-page__actions">
|
||||
<button
|
||||
class="mt-act-btn mt-act-btn--primary"
|
||||
v-tooltip.bottom="'Nova tag'"
|
||||
:disabled="loading"
|
||||
@click="abrirCriar"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Nova</span>
|
||||
</button>
|
||||
<button
|
||||
class="mt-head-btn"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button class="mt-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-body">
|
||||
<Teleport to="#mt-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mt-side">
|
||||
<div class="mt-w">
|
||||
<div class="mt-w__head">
|
||||
<span class="mt-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
|
||||
</div>
|
||||
<div class="mt-stats">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 4" :key="`stsk-${i}`" class="mt-stat" aria-busy="true">
|
||||
<div class="mt-stat__val melissa-skeleton melissa-skeleton--number" />
|
||||
<div class="mt-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="s in stats"
|
||||
v-else
|
||||
:key="s.key"
|
||||
class="mt-stat"
|
||||
:class="`is-${s.cls}`"
|
||||
>
|
||||
<div class="mt-stat__val">{{ s.value }}</div>
|
||||
<div class="mt-stat__lbl">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-w">
|
||||
<div class="mt-w__head">
|
||||
<span class="mt-w__title"><i class="pi pi-search" /> Buscar</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="busca"
|
||||
type="text"
|
||||
placeholder="Nome da tag…"
|
||||
class="mt-search__input"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<div class="mt-main">
|
||||
<div class="mt-list">
|
||||
<template v-if="carregandoInicial">
|
||||
<div v-for="i in 5" :key="`tsk-${i}`" class="mt-card mt-card--skeleton" aria-busy="true">
|
||||
<span class="mt-card__dot melissa-skeleton" style="border-radius: 50%;" />
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
|
||||
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="tagsFiltradas.length === 0" class="mt-empty">
|
||||
<i class="pi pi-tag mt-empty__icon" />
|
||||
<div class="mt-empty__title">Nenhuma tag encontrada</div>
|
||||
<div class="mt-empty__hint">
|
||||
<template v-if="busca">Ajuste a busca pra ver mais resultados.</template>
|
||||
<template v-else>Crie sua primeira tag pra organizar pacientes.</template>
|
||||
</div>
|
||||
<button class="mt-act-btn mt-act-btn--primary" @click="abrirCriar">
|
||||
<i class="pi pi-plus" /><span>Nova tag</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="t in tagsFiltradas"
|
||||
v-else
|
||||
:key="t.id"
|
||||
class="mt-card"
|
||||
:class="{ 'is-padrao': t.is_padrao }"
|
||||
@click="abrirEditar(t)"
|
||||
>
|
||||
<span class="mt-card__dot" :style="{ background: t.cor || '#64748b' }" />
|
||||
<div class="mt-card__main">
|
||||
<div class="mt-card__name-row">
|
||||
<span class="mt-card__name">{{ t.nome }}</span>
|
||||
<span v-if="t.is_padrao" class="mt-card__badge">Padrão</span>
|
||||
</div>
|
||||
<div class="mt-card__meta">
|
||||
<span><i class="pi pi-users" /> {{ t.pacientes_count }} {{ t.pacientes_count === 1 ? 'paciente' : 'pacientes' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-card__actions" @click.stop>
|
||||
<button
|
||||
v-if="!t.is_padrao"
|
||||
class="mt-card__btn"
|
||||
v-tooltip.left="'Editar'"
|
||||
@click="abrirEditar(t)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!t.is_padrao"
|
||||
class="mt-card__btn mt-card__btn--danger"
|
||||
v-tooltip.left="'Excluir'"
|
||||
@click="confirmarExcluir(t)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog create/edit -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
dismissable-mask
|
||||
:style="{ width: '380px', maxWidth: '92vw' }"
|
||||
:header="dlgMode === 'create' ? 'Nova tag' : 'Editar tag'"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Nome
|
||||
<InputText v-model="dlgForm.nome" placeholder="Ex: TDAH, VIP, Convênio…" class="w-full mt-1" autofocus @keydown.enter="salvar" />
|
||||
</label>
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">
|
||||
Cor
|
||||
<input v-model="dlgForm.cor" type="color" class="w-full h-9 mt-1 rounded-md border border-[var(--surface-border)] bg-transparent cursor-pointer" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="c in PRESET_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="w-6 h-6 rounded-full border border-white/20 transition-transform hover:scale-110"
|
||||
:style="{ background: '#' + c }"
|
||||
@click="dlgForm.cor = '#' + c"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dlgError" class="text-xs text-red-400">{{ dlgError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="dlgOpen = false" />
|
||||
<Button :label="saving ? 'Salvando…' : 'Salvar'" :loading="saving" :disabled="!dlgForm.nome.trim() || saving" @click="salvar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mt-page {
|
||||
position: absolute;
|
||||
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(32px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
color: var(--m-text);
|
||||
animation: mt-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@keyframes mt-page-enter {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mt-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mt-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mt-page__title > span:not(.mt-page__count) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mt-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mt-close,
|
||||
.mt-head-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.mt-close:hover, .mt-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.mt-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
.mt-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
}
|
||||
.mt-act-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mt-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.mt-menu-btn {
|
||||
display: none;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
color: white;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mt-menu-btn:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mt-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.mt-side {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mt-side::-webkit-scrollbar { width: 5px; }
|
||||
.mt-side::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mt-w {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.mt-w__head { margin-bottom: 10px; }
|
||||
.mt-w__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mt-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
|
||||
|
||||
.mt-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mt-stat {
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.mt-stat__val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mt-stat__lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mt-stat.is-ok .mt-stat__val { color: rgb(74, 222, 128); }
|
||||
|
||||
.mt-search__input {
|
||||
width: 100%;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.mt-search__input:focus {
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
|
||||
.mt-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mt-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mt-list::-webkit-scrollbar { width: 5px; }
|
||||
.mt-list::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mt-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mt-card:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mt-card.is-padrao { cursor: default; }
|
||||
.mt-card.is-padrao:hover { transform: none; }
|
||||
.mt-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
|
||||
.mt-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
|
||||
|
||||
.mt-card__dot {
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.mt-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mt-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mt-card__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mt-card__meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.mt-card__meta i { margin-right: 4px; }
|
||||
|
||||
.mt-card__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mt-card__btn {
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mt-card__btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
|
||||
.mt-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mt-card__btn > i { font-size: 0.7rem; }
|
||||
|
||||
.mt-empty {
|
||||
margin: 24px 0;
|
||||
padding: 56px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
border: 2px dashed var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
|
||||
gap: 8px;
|
||||
}
|
||||
.mt-empty__icon {
|
||||
font-size: 2rem;
|
||||
color: var(--m-text-faint);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mt-empty__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mt-empty__hint {
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Drawer mobile */
|
||||
.mt-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mt-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mt-mobile-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mt-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mt-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mt-mobile-drawer__scroll .mt-side {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.mt-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mt-drawer-fade-enter-active,
|
||||
.mt-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mt-drawer-fade-enter-from,
|
||||
.mt-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mt-body { flex-direction: column; padding: 8px; }
|
||||
.mt-main { width: 100%; }
|
||||
.mt-page__title > span:first-of-type { display: none; }
|
||||
.mt-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mt-act-btn span { display: none; }
|
||||
.mt-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -24,7 +24,7 @@
|
||||
* Os handlers exibem toasts (success/warn) — o composable assume que os
|
||||
* componentes consumidores já registraram `<Toast />` e `<ConfirmDialog />`.
|
||||
*/
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
@@ -322,11 +322,38 @@ export function useMelissaAgenda() {
|
||||
// ── Inicialização ───────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
await loadDeterminedCommitments();
|
||||
const tid = clinicTenantId.value;
|
||||
if (tid) await loadFeriadosBase(tid);
|
||||
});
|
||||
|
||||
// Refetch settings + workRules quando o user salva jornada/ritmo/online
|
||||
// em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline
|
||||
// do resumo continuaria mostrando o range antigo até reload da página.
|
||||
function _onSettingsSaved() {
|
||||
loadSettings();
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('agenda:settings-saved', _onSettingsSaved);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('agenda:settings-saved', _onSettingsSaved);
|
||||
});
|
||||
|
||||
// Commitments + feriados dependem do tenant. Em refresh "frio", o
|
||||
// tenantStore ainda não terminou de hidratar quando o composable
|
||||
// monta, e clinicTenantId fica null. loadDeterminedCommitments faz
|
||||
// bail-out silencioso quando tenantId é vazio (rows = [], sem retry)
|
||||
// — daí o "às vezes" do bug onde commitmentOptions chegava vazio no
|
||||
// AgendaEventDialog. Watch com immediate: true dispara já se o tenant
|
||||
// estiver pronto, ou no momento exato em que ele aparecer.
|
||||
watch(
|
||||
clinicTenantId,
|
||||
async (tid) => {
|
||||
if (!tid) return;
|
||||
await loadDeterminedCommitments();
|
||||
await loadFeriadosBase(tid);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Reload quando view muda OU quando settings/ownerId aparece
|
||||
watch([viewStart, viewEnd], _reloadRange);
|
||||
watch(ownerId, (v) => {
|
||||
|
||||
@@ -63,7 +63,9 @@ function normalizeEvent(r) {
|
||||
fim_em: r.fim_em,
|
||||
startH: isoToDecimalHour(r.inicio_em),
|
||||
endH: isoToDecimalHour(r.fim_em),
|
||||
dateKey: String(r.inicio_em || '').slice(0, 10)
|
||||
dateKey: String(r.inicio_em || '').slice(0, 10),
|
||||
price: r.price != null ? Number(r.price) : 0,
|
||||
billed: !!r.billed
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +83,7 @@ async function _fetchRange(start, end) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start.toISOString())
|
||||
@@ -185,6 +187,137 @@ export function useMelissaEventosRange(startRef, endRef) {
|
||||
return { eventos, loading, error, refetch: fetch };
|
||||
}
|
||||
|
||||
// ── Busca server-side: por nome de paciente ou título do evento ──
|
||||
// Usado pela busca da toolbar do MelissaAgenda. Procura sem range temporal
|
||||
// (acha sessões fora do que está visível no FC). Limite de 20 resultados,
|
||||
// ordenados por inicio_em DESC (mais recente primeiro).
|
||||
//
|
||||
// Acento-insensitive: troca cada vogal/c do termo por um character class
|
||||
// que casa com todas as variantes ("andre" vira "[aáàâã]ndr[eéèê]") e usa
|
||||
// `imatch` (operador POSIX `~*` do Postgres — case-insensitive regex).
|
||||
// Estratégia evita depender da extensão `unaccent` no DB.
|
||||
function _buildAccentInsensitivePattern(term) {
|
||||
// Escapa regex specials primeiro pra não quebrar o pattern.
|
||||
const escaped = String(term || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const map = {
|
||||
a: '[aáàâãAÁÀÂÃ]', e: '[eéèêEÉÈÊ]', i: '[iíìîIÍÌÎ]',
|
||||
o: '[oóòôõOÓÒÔÕ]', u: '[uúùûUÚÙÛ]', c: '[cçCÇ]',
|
||||
A: '[aáàâãAÁÀÂÃ]', E: '[eéèêEÉÈÊ]', I: '[iíìîIÍÌÎ]',
|
||||
O: '[oóòôõOÓÒÔÕ]', U: '[uúùûUÚÙÛ]', C: '[cçCÇ]'
|
||||
};
|
||||
return escaped.split('').map((ch) => map[ch] || ch).join('');
|
||||
}
|
||||
|
||||
export async function searchEventosByText(termo) {
|
||||
const term = String(termo || '').trim();
|
||||
if (term.length < 2) return [];
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const userId = userData?.user?.id || null;
|
||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||
await tenantStore.ensureLoaded();
|
||||
}
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
if (!userId || !tid) return [];
|
||||
|
||||
const SELECT = 'id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)';
|
||||
const pattern = _buildAccentInsensitivePattern(term);
|
||||
|
||||
try {
|
||||
const [byPatient, byTitle] = await Promise.all([
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select(SELECT.replace('patients!agenda_eventos_patient_id_fkey', 'patients!inner!agenda_eventos_patient_id_fkey'))
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.filter('patients.nome_completo', 'imatch', pattern)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select(SELECT)
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.filter('titulo', 'imatch', pattern)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(20)
|
||||
]);
|
||||
if (byPatient.error) throw byPatient.error;
|
||||
if (byTitle.error) throw byTitle.error;
|
||||
|
||||
const merged = [...(byPatient.data || []), ...(byTitle.data || [])];
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const r of merged) {
|
||||
if (seen.has(r.id)) continue;
|
||||
seen.add(r.id);
|
||||
unique.push(r);
|
||||
}
|
||||
unique.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
||||
return unique.slice(0, 20).map(normalizeEvent);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[searchEventosByText]', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── COMPOSABLE 4: todas as sessões de um paciente (sem range) ──
|
||||
// Usado pelo banner "Ver todas" da MelissaAgenda quando o usuário
|
||||
// quer escapar do range visível e ver o histórico completo do
|
||||
// paciente selecionado. Diferente do useMelissaEventosRange, aqui
|
||||
// filtramos por patient_id e ignoramos qualquer range temporal.
|
||||
//
|
||||
// Retorna eventos ordenados por inicio_em DESC (mais recente primeiro)
|
||||
// — coerente com listas de "histórico" no resto do sistema.
|
||||
export function useMelissaTodasSessoesPaciente() {
|
||||
const eventos = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function fetch(patientId) {
|
||||
if (!patientId) { eventos.value = []; return; }
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const tenantStore = useTenantStore();
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const userId = userData?.user?.id || null;
|
||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||
await tenantStore.ensureLoaded();
|
||||
}
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
if (!userId || !tid) { eventos.value = []; return; }
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', userId)
|
||||
.eq('patient_id', patientId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.order('inicio_em', { ascending: false });
|
||||
|
||||
if (err) throw err;
|
||||
eventos.value = (data || []).map(normalizeEvent);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar sessões';
|
||||
eventos.value = [];
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaTodasSessoesPaciente]', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
eventos.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return { eventos, loading, error, fetch, reset };
|
||||
}
|
||||
|
||||
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
|
||||
export function useMelissaEventosHoje() {
|
||||
const eventos = ref([]);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* useMelissaPacientesAside — paginação async DEDICADA pra coluna direita
|
||||
* da MelissaAgenda (lista de "Pacientes" lá no aside).
|
||||
* --------------------------------------------------
|
||||
* Por que existe (em vez de reaproveitar `useMelissaPacientes`):
|
||||
* - useMelissaPacientes carrega TUDO num array só (até 1000) porque outras
|
||||
* partes do sistema dependem da lista completa em memória (lookup por ID
|
||||
* em eventos da agenda, página MelissaPacientes, cards de resumo).
|
||||
* - A coluna do aside só precisa renderizar 6 por vez. Pra clínicas com
|
||||
* milhares de pacientes, faz sentido essa coluna ir ao banco a cada
|
||||
* página/busca em vez de paginar client-side em cima de um array gigante.
|
||||
*
|
||||
* Compromisso: a ordenação é alfabética (server-side por nome_completo). O
|
||||
* destaque visual de "novo paciente" continua funcionando (compara created_at
|
||||
* < 7 dias no consumer), mas pacientes novos NÃO são mais empurrados pro
|
||||
* topo da lista — eles aparecem na ordem alfabética normal. Pra ver os mais
|
||||
* recentes, usuário precisa filtrar/buscar pelo nome.
|
||||
*
|
||||
* Sanitização (regra do projeto):
|
||||
* - busca trimada e capada em 100 chars
|
||||
* - wildcards LIKE (%, _) são escapados antes de irem pro .ilike()
|
||||
*
|
||||
* Race-safety:
|
||||
* - sequence number (_seq) ignora respostas tardias de queries antigas
|
||||
* quando o usuário troca de página/digita rápido.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const SEARCH_MAX_LEN = 100;
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
function normalizeStatus(s) {
|
||||
const v = String(s || '').toLowerCase().trim();
|
||||
if (!v) return 'Ativo';
|
||||
if (v === 'active' || v === 'ativo') return 'Ativo';
|
||||
if (v === 'inactive' || v === 'inativo') return 'Inativo';
|
||||
return v.charAt(0).toUpperCase() + v.slice(1);
|
||||
}
|
||||
|
||||
// Escapa wildcards do LIKE/ILIKE pra evitar que o usuário injete
|
||||
// padrões de busca não intencionais ao digitar % ou _.
|
||||
function escapeLike(s) {
|
||||
return String(s).replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {import('vue').Ref<number>} opts.pagina ref 1-based
|
||||
* @param {import('vue').Ref<string>} opts.busca ref de string (texto livre)
|
||||
* @param {number} [opts.porPagina=6] tamanho da página
|
||||
*/
|
||||
export function useMelissaPacientesAside(opts) {
|
||||
const { pagina, busca, porPagina = 6 } = opts;
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const pacientes = ref([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const _uid = ref(null);
|
||||
const _seq = ref(0);
|
||||
let _debounceTimer = null;
|
||||
|
||||
async function _ensureUid() {
|
||||
if (_uid.value) return _uid.value;
|
||||
const { data, error: err } = await supabase.auth.getUser();
|
||||
if (err) return null;
|
||||
_uid.value = data?.user?.id || null;
|
||||
return _uid.value;
|
||||
}
|
||||
|
||||
async function _fetch() {
|
||||
const seq = ++_seq.value;
|
||||
const userId = await _ensureUid();
|
||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||
await tenantStore.ensureLoaded();
|
||||
}
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
|
||||
if (!userId || !tid) {
|
||||
if (seq !== _seq.value) return;
|
||||
pacientes.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitização da busca: trim + cap + escape de wildcards LIKE.
|
||||
const rawQ = String(busca.value || '').trim().slice(0, SEARCH_MAX_LEN);
|
||||
const hasQ = rawQ.length > 0;
|
||||
|
||||
const start = Math.max(0, (pagina.value - 1) * porPagina);
|
||||
const end = start + porPagina - 1;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
let q = supabase
|
||||
.from('patients')
|
||||
.select(
|
||||
'id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento',
|
||||
{ count: 'exact' }
|
||||
)
|
||||
.eq('owner_id', userId)
|
||||
.eq('tenant_id', tid)
|
||||
// Mesmo critério do useMelissaPacientes original (onlyActive=true).
|
||||
// DB tem valores variados ('ativo'/'Ativo'/'active'); aceita os 3.
|
||||
.in('status', ['ativo', 'Ativo', 'active'])
|
||||
.order('nome_completo', { ascending: true })
|
||||
.range(start, end);
|
||||
|
||||
if (hasQ) {
|
||||
q = q.ilike('nome_completo', `%${escapeLike(rawQ)}%`);
|
||||
}
|
||||
|
||||
const { data, error: err, count } = await q;
|
||||
if (err) throw err;
|
||||
|
||||
// Race-guard: outra chamada disparou enquanto esperávamos a resposta.
|
||||
if (seq !== _seq.value) return;
|
||||
|
||||
pacientes.value = (data || []).map((r) => ({
|
||||
id: r.id,
|
||||
nome: r.nome_completo || '',
|
||||
email: r.email_principal || '',
|
||||
telefone: r.telefone || '',
|
||||
avatar_url: r.avatar_url || null,
|
||||
status: normalizeStatus(r.status),
|
||||
last_attended_at: r.last_attended_at || null,
|
||||
created_at: r.created_at || null,
|
||||
data_nascimento: r.data_nascimento || null
|
||||
}));
|
||||
total.value = count ?? 0;
|
||||
} catch (e) {
|
||||
if (seq !== _seq.value) return;
|
||||
error.value = e?.message || 'Erro ao carregar pacientes';
|
||||
pacientes.value = [];
|
||||
total.value = 0;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaPacientesAside]', e);
|
||||
} finally {
|
||||
if (seq === _seq.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleFetch({ debounce }) {
|
||||
if (_debounceTimer) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
if (debounce) {
|
||||
_debounceTimer = setTimeout(() => {
|
||||
_debounceTimer = null;
|
||||
_fetch();
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
_fetch();
|
||||
}
|
||||
}
|
||||
|
||||
// Página muda → fetch imediato (clique no paginator é deliberado).
|
||||
watch(pagina, () => _scheduleFetch({ debounce: false }), { immediate: true });
|
||||
// Busca muda → debounce (usuário digitando).
|
||||
watch(busca, () => {
|
||||
// Reset implícito: ao buscar, qualquer página > 1 deve voltar pra 1.
|
||||
// Como `pagina` é refs do consumer, não mexemos aqui — o consumer faz isso.
|
||||
_scheduleFetch({ debounce: true });
|
||||
});
|
||||
|
||||
const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / porPagina)));
|
||||
|
||||
return {
|
||||
pacientes,
|
||||
total,
|
||||
totalPaginas,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => _scheduleFetch({ debounce: false })
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* useMelissaWhatsapp — agregado leve pro card "WhatsApp" do resumo Melissa.
|
||||
*
|
||||
* Lê da view `conversation_threads` (mesma fonte do drawer/kanban):
|
||||
* - count = soma de unread_count em threads WhatsApp não-lidas
|
||||
* - top1 = thread mais recente com mensagens não-lidas (preview)
|
||||
*
|
||||
* Filtra channel='whatsapp' pra coerência com o título do card. Inclui
|
||||
* só threads com unread_count > 0 (limit 50 — payload pequeno e cobre
|
||||
* praticamente qualquer clínica; se passar disso, o count fica ligeiramente
|
||||
* subestimado mas o card já cumpre o papel de "alerta visual").
|
||||
*
|
||||
* Sem realtime no MVP — refetch manual via `refetch()`. Quando quiser
|
||||
* atualização instantânea, plugar a subscription do `useConversations`
|
||||
* (channel `conv_msg_tenant_<tid>` em conversation_messages INSERT).
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const EMPTY = { count: 0, ultimaMsg: '', ultimoNome: '', ultimaEm: null };
|
||||
|
||||
export function useMelissaWhatsapp() {
|
||||
const summary = ref({ ...EMPTY });
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function fetch() {
|
||||
const tenantStore = useTenantStore();
|
||||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||
await tenantStore.ensureLoaded();
|
||||
}
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
if (!tid) { summary.value = { ...EMPTY }; return; }
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_threads')
|
||||
.select('patient_name, contact_number, unread_count, last_message_body, last_message_at, last_message_direction')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('channel', 'whatsapp')
|
||||
.gt('unread_count', 0)
|
||||
.order('last_message_at', { ascending: false })
|
||||
.limit(50);
|
||||
if (err) throw err;
|
||||
|
||||
const rows = data || [];
|
||||
const totalUnread = rows.reduce((s, t) => s + Number(t.unread_count || 0), 0);
|
||||
const top = rows[0] || null;
|
||||
summary.value = {
|
||||
count: totalUnread,
|
||||
ultimaMsg: String(top?.last_message_body || '').trim(),
|
||||
ultimoNome: String(top?.patient_name || top?.contact_number || '').trim() || '—',
|
||||
ultimaEm: top?.last_message_at || null
|
||||
};
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaWhatsapp]', e);
|
||||
error.value = e?.message || 'Erro ao carregar WhatsApp';
|
||||
summary.value = { ...EMPTY };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetch);
|
||||
|
||||
return { summary, loading, error, refetch: fetch };
|
||||
}
|
||||
Reference in New Issue
Block a user