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:
@@ -99,6 +99,7 @@
|
||||
transition: opacity 0.12s;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.layout-menuitem-text {
|
||||
|
||||
@@ -36,6 +36,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom PrimeVue */
|
||||
|
||||
.p-floatlabel-on:has(input.p-filled) label,
|
||||
.p-floatlabel-on:has(.p-inputwrapper-filled) label,
|
||||
.p-floatlabel-on:has(textarea.p-filled) label {
|
||||
padding: 2px 4px !important;
|
||||
border: 1px solid var(--p-inputtext-border-color);
|
||||
border-radius: 4px !important;
|
||||
left: 10px !important;
|
||||
top: -2px !important;
|
||||
}
|
||||
|
||||
.p-floatlabel > label[for="f_nasc"] {
|
||||
left: 44px !important;
|
||||
}
|
||||
|
||||
.p-floatlabel:has(input.p-filled) label, .p-floatlabel:has(textarea.p-filled) label, .p-floatlabel:has(.p-inputwrapper-filled) label {
|
||||
color: var(--p-floatlabel-focus-color) !important;
|
||||
}
|
||||
|
||||
.p-dialog-header {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.p-dialog-footer {
|
||||
padding: 0.6rem !important;
|
||||
}
|
||||
|
||||
/* Highlight pulse (acionado externamente via classe JS) */
|
||||
@keyframes highlight-pulse {
|
||||
0% {
|
||||
|
||||
@@ -181,9 +181,9 @@ function close () { emit('update:visible', false) }
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
|
||||
@@ -298,9 +298,9 @@ function close () {
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
|
||||
@@ -193,9 +193,9 @@ function skipProcedures() {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { isToday, isYesterday, differenceInDays } from 'date-fns';
|
||||
import {
|
||||
useNotificationStore,
|
||||
requestBrowserNotificationPermission,
|
||||
@@ -69,6 +70,26 @@ const drawerOpen = computed({
|
||||
|
||||
const displayedItems = computed(() => (filter.value === 'unread' ? store.unreadItems : store.allItems));
|
||||
|
||||
// Agrupa por bucket temporal: Hoje / Ontem / Esta semana / Mais antigas.
|
||||
// Mantém ordem original (já vem desc do store).
|
||||
const groupedItems = computed(() => {
|
||||
const buckets = { hoje: [], ontem: [], semana: [], antigas: [] };
|
||||
const now = new Date();
|
||||
for (const item of displayedItems.value) {
|
||||
const d = new Date(item.created_at);
|
||||
if (isToday(d)) buckets.hoje.push(item);
|
||||
else if (isYesterday(d)) buckets.ontem.push(item);
|
||||
else if (differenceInDays(now, d) <= 7) buckets.semana.push(item);
|
||||
else buckets.antigas.push(item);
|
||||
}
|
||||
return [
|
||||
{ key: 'hoje', label: 'Hoje', items: buckets.hoje },
|
||||
{ key: 'ontem', label: 'Ontem', items: buckets.ontem },
|
||||
{ key: 'semana', label: 'Esta semana', items: buckets.semana },
|
||||
{ key: 'antigas', label: 'Mais antigas', items: buckets.antigas }
|
||||
].filter((g) => g.items.length > 0);
|
||||
});
|
||||
|
||||
function handleRead(id) {
|
||||
store.markRead(id);
|
||||
// Fecha o drawer e deixa a navegação acontecer
|
||||
@@ -86,53 +107,109 @@ function goToHistory() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer v-model:visible="drawerOpen" position="right" :style="{ width: '380px' }" :pt="{ header: { class: 'notification-drawer__header' } }">
|
||||
<Drawer
|
||||
v-model:visible="drawerOpen"
|
||||
position="right"
|
||||
:style="{ width: '420px' }"
|
||||
:pt="{ header: { class: 'notification-drawer__header' } }"
|
||||
>
|
||||
<!-- Header -->
|
||||
<template #header>
|
||||
<div class="notification-drawer__header-content">
|
||||
<span class="notification-drawer__title">Notificações</span>
|
||||
<Badge v-if="store.unreadCount > 0" :value="store.unreadCount > 99 ? '99+' : store.unreadCount" severity="danger" />
|
||||
<Button
|
||||
:icon="browserNotifOn ? 'pi pi-bell' : 'pi pi-bell-slash'"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="ml-auto"
|
||||
<div class="notification-drawer__title-wrap">
|
||||
<span class="notification-drawer__title">Notificações</span>
|
||||
<span v-if="store.unreadCount > 0" class="notification-drawer__count-pill">
|
||||
{{ store.unreadCount > 99 ? '99+' : store.unreadCount }} não lida{{ store.unreadCount === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="notification-drawer__icon-btn"
|
||||
:class="{ 'notification-drawer__icon-btn--active': browserNotifOn }"
|
||||
:title="browserNotifOn ? 'Desativar notificações do browser' : 'Ativar notificações do browser'"
|
||||
@click="toggleBrowserNotif"
|
||||
/>
|
||||
>
|
||||
<i :class="['pi', browserNotifOn ? 'pi-bell' : 'pi-bell-slash']" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="notification-drawer__body">
|
||||
<!-- Ação em lote -->
|
||||
<!-- Toolbar: tabs + mark-all -->
|
||||
<div class="notification-drawer__toolbar">
|
||||
<!-- Filtro tabs -->
|
||||
<div class="notification-drawer__tabs">
|
||||
<button class="notification-drawer__tab" :class="{ 'notification-drawer__tab--active': filter === 'unread' }" @click="filter = 'unread'">
|
||||
<div class="notification-drawer__tabs" role="tablist">
|
||||
<button
|
||||
class="notification-drawer__tab"
|
||||
:class="{ 'notification-drawer__tab--active': filter === 'unread' }"
|
||||
role="tab"
|
||||
:aria-selected="filter === 'unread'"
|
||||
@click="filter = 'unread'"
|
||||
>
|
||||
Não lidas
|
||||
<span v-if="store.unreadCount > 0" class="notification-drawer__tab-count">
|
||||
{{ store.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
<button class="notification-drawer__tab" :class="{ 'notification-drawer__tab--active': filter === 'all' }" @click="filter = 'all'">Todas</button>
|
||||
<button
|
||||
class="notification-drawer__tab"
|
||||
:class="{ 'notification-drawer__tab--active': filter === 'all' }"
|
||||
role="tab"
|
||||
:aria-selected="filter === 'all'"
|
||||
@click="filter = 'all'"
|
||||
>Todas</button>
|
||||
</div>
|
||||
|
||||
<Button v-if="store.unreadCount > 0" link size="small" label="Marcar todas como lidas" @click="store.markAllRead()" class="notification-drawer__mark-all" />
|
||||
<button
|
||||
v-if="store.unreadCount > 0"
|
||||
type="button"
|
||||
class="notification-drawer__mark-all"
|
||||
@click="store.markAllRead()"
|
||||
>
|
||||
<i class="pi pi-check-square" />
|
||||
<span>Marcar todas</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<!-- Lista agrupada por data -->
|
||||
<div v-if="displayedItems.length > 0" class="notification-drawer__list">
|
||||
<NotificationItem v-for="item in displayedItems" :key="item.id" :item="item" @read="handleRead" @archive="handleArchive" />
|
||||
<section
|
||||
v-for="group in groupedItems"
|
||||
:key="group.key"
|
||||
class="notification-drawer__group"
|
||||
>
|
||||
<header class="notification-drawer__group-head">
|
||||
<span class="notification-drawer__group-label">{{ group.label }}</span>
|
||||
<span class="notification-drawer__group-line" />
|
||||
<span class="notification-drawer__group-count">{{ group.items.length }}</span>
|
||||
</header>
|
||||
<NotificationItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@read="handleRead"
|
||||
@archive="handleArchive"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="notification-drawer__empty">
|
||||
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
|
||||
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
|
||||
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
|
||||
<div class="notification-drawer__empty-art" aria-hidden="true">
|
||||
<i class="pi pi-check-circle" />
|
||||
</div>
|
||||
<p class="notification-drawer__empty-text">Tudo em dia por aqui</p>
|
||||
<p class="notification-drawer__empty-sub">
|
||||
{{ filter === 'unread' ? 'Nenhuma notificação não lida no momento.' : 'Nenhuma notificação ainda.' }}
|
||||
</p>
|
||||
<button
|
||||
v-if="filter === 'unread' && store.allItems.length > 0"
|
||||
type="button"
|
||||
class="notification-drawer__empty-link"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
Ver tudo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +218,8 @@ function goToHistory() {
|
||||
<div class="notification-drawer__footer">
|
||||
<button class="notification-drawer__history-link" @click="goToHistory">
|
||||
<i class="pi pi-history" />
|
||||
Ver histórico completo →
|
||||
<span>Ver histórico completo</span>
|
||||
<i class="pi pi-arrow-right notification-drawer__history-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -149,59 +227,113 @@ function goToHistory() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Header ─────────────────────────────────────────────────── */
|
||||
.notification-drawer__header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
.notification-drawer__title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-drawer__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.notification-drawer__count-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.notification-drawer__icon-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card, transparent);
|
||||
color: var(--text-color-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.notification-drawer__icon-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.notification-drawer__icon-btn--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Body ───────────────────────────────────────────────────── */
|
||||
.notification-drawer__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--surface-ground, #f8fafc) 60%, var(--surface-card, #fff));
|
||||
}
|
||||
|
||||
/* ─── Toolbar (tabs + mark-all) ───────────────────────────────── */
|
||||
.notification-drawer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--surface-card);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notification-drawer__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
background: var(--surface-100, color-mix(in srgb, var(--text-color) 6%, transparent));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.notification-drawer__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
.notification-drawer__tab:hover { color: var(--text-color); }
|
||||
.notification-drawer__tab--active {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--surface-card, #fff);
|
||||
color: var(--primary-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.notification-drawer__tab-count {
|
||||
display: inline-flex;
|
||||
@@ -209,71 +341,169 @@ function goToHistory() {
|
||||
justify-content: center;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.notification-drawer__tab--active .notification-drawer__tab-count {
|
||||
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.notification-drawer__mark-all {
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem !important;
|
||||
padding: 0 !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.32rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.notification-drawer__mark-all:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.notification-drawer__mark-all i { font-size: 0.78rem; }
|
||||
|
||||
/* ─── Lista + grupos por data ─────────────────────────────────── */
|
||||
.notification-drawer__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-300, #cbd5e1) transparent;
|
||||
}
|
||||
.notification-drawer__list::-webkit-scrollbar { width: 6px; }
|
||||
.notification-drawer__list::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-300, #cbd5e1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.notification-drawer__group {
|
||||
/* o NotificationItem já tem margin lateral */
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.notification-drawer__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem 0.35rem;
|
||||
}
|
||||
.notification-drawer__group-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notification-drawer__group-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--surface-border);
|
||||
}
|
||||
.notification-drawer__group-count {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--text-color) 6%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Empty state ─────────────────────────────────────────────── */
|
||||
.notification-drawer__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 1rem;
|
||||
gap: 0.6rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.notification-drawer__empty-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.4;
|
||||
.notification-drawer__empty-art {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.notification-drawer__empty-art i { font-size: 1.8rem; }
|
||||
.notification-drawer__empty-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.notification-drawer__empty-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.notification-drawer__empty-link {
|
||||
margin-top: 0.6rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.notification-drawer__empty-link:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Footer ──────────────────────────────────────────────────── */
|
||||
.notification-drawer__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notification-drawer__history-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary-color);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: opacity 0.15s;
|
||||
padding: 0.45rem 0.95rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.notification-drawer__history-link:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
.notification-drawer__history-arrow {
|
||||
font-size: 0.7rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.notification-drawer__history-link:hover .notification-drawer__history-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,14 +34,17 @@ const store = useNotificationStore();
|
||||
const conversationDrawer = useConversationDrawerStore();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// Cores por tipo (usadas pra ícone + chips). RGB explícito pra usar com
|
||||
// color-mix() no scoped CSS sem depender de tokens var().
|
||||
const typeMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500' },
|
||||
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500' },
|
||||
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500' },
|
||||
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' },
|
||||
inbound_message: { icon: 'pi-whatsapp', border: 'border-emerald-500' },
|
||||
system_alert: { icon: 'pi-exclamation-circle', border: 'border-red-600' }
|
||||
new_scheduling: { icon: 'pi-inbox', label: 'Agendamento', rgb: '244, 63, 94' }, // rose-500
|
||||
new_patient: { icon: 'pi-user-plus', label: 'Novo paciente', rgb: '14, 165, 233' }, // sky-500
|
||||
recurrence_alert: { icon: 'pi-refresh', label: 'Recorrência', rgb: '245, 158, 11' }, // amber-500
|
||||
session_status: { icon: 'pi-calendar-times', label: 'Sessão', rgb: '249, 115, 22' }, // orange-500
|
||||
inbound_message: { icon: 'pi-whatsapp', label: 'WhatsApp', rgb: '16, 185, 129' }, // emerald-500
|
||||
system_alert: { icon: 'pi-exclamation-circle', label: 'Alerta', rgb: '239, 68, 68' } // red-500
|
||||
};
|
||||
const DEFAULT_TYPE = { icon: 'pi-bell', label: '', rgb: '99, 102, 241' };
|
||||
|
||||
// Aliases semânticos do deeplink → rota real por role. Mesmo map do AppLayout.
|
||||
const DEEPLINK_ALIASES = {
|
||||
@@ -57,11 +60,17 @@ function resolveDeeplink(link) {
|
||||
return alias[role] || alias.therapist;
|
||||
}
|
||||
|
||||
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' });
|
||||
const meta = computed(() => typeMap[props.item.type] || DEFAULT_TYPE);
|
||||
const isUnread = computed(() => !props.item.read_at);
|
||||
|
||||
const timeAgo = computed(() => formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR }));
|
||||
|
||||
// CSS vars injetadas no item via :style — permite color-mix() no scoped CSS
|
||||
// sem precisar de N classes. `--type-rgb` segue a paleta do typeMap.
|
||||
const itemStyle = computed(() => ({
|
||||
'--type-rgb': meta.value.rgb
|
||||
}));
|
||||
|
||||
const initials = computed(() => props.item.payload?.avatar_initials || '?');
|
||||
|
||||
async function openConversationByThreadKey(threadKey) {
|
||||
@@ -161,45 +170,54 @@ function handleArchive(e) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notif-item" :class="[meta.border, isUnread ? 'notif-item--unread' : '']" role="button" tabindex="0" @click="handleRowClick" @keydown.enter="handleRowClick">
|
||||
<!-- Ícone do tipo -->
|
||||
<div class="notif-item__icon" aria-hidden="true">
|
||||
<i :class="['pi', meta.icon]" />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="notif-item__avatar" aria-hidden="true">
|
||||
{{ initials }}
|
||||
<div
|
||||
class="notif-item"
|
||||
:class="{ 'notif-item--unread': isUnread }"
|
||||
:style="itemStyle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleRowClick"
|
||||
@keydown.enter="handleRowClick"
|
||||
>
|
||||
<!-- Ícone do tipo (avatar de iniciais quando vier do payload, senão o ícone) -->
|
||||
<div class="notif-item__type" aria-hidden="true">
|
||||
<span v-if="item.payload?.avatar_initials" class="notif-item__avatar">{{ initials }}</span>
|
||||
<i v-else :class="['pi', meta.icon, 'notif-item__icon']" />
|
||||
<span v-if="isUnread" class="notif-item__unread-dot" aria-label="Não lida" />
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="notif-item__body">
|
||||
<div class="notif-item__head">
|
||||
<span class="notif-item__type-label">{{ meta.label }}</span>
|
||||
<span class="notif-item__time">{{ timeAgo }}</span>
|
||||
</div>
|
||||
<p class="notif-item__title">{{ item.payload?.title }}</p>
|
||||
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
|
||||
<div class="notif-item__footer">
|
||||
<p class="notif-item__time">{{ timeAgo }}</p>
|
||||
<div v-if="item.payload?.thread_key || item.payload?.deeplink" class="notif-item__quick" @click.stop>
|
||||
<button
|
||||
v-if="item.payload?.thread_key"
|
||||
class="notif-quick-btn"
|
||||
title="Abrir conversa"
|
||||
@click="handleOpenConversation">
|
||||
<i class="pi pi-comment" />
|
||||
<span>Conversa</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.payload?.deeplink"
|
||||
class="notif-quick-btn"
|
||||
:title="item.payload?.actionLabel || 'Abrir'"
|
||||
@click="handleOpenDeeplink">
|
||||
<i class="pi pi-arrow-right" />
|
||||
<span>{{ item.payload?.actionLabel || 'Abrir' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="item.payload?.detail" class="notif-item__detail">{{ item.payload.detail }}</p>
|
||||
|
||||
<div v-if="item.payload?.thread_key || item.payload?.deeplink" class="notif-item__quick" @click.stop>
|
||||
<button
|
||||
v-if="item.payload?.thread_key"
|
||||
class="notif-quick-btn"
|
||||
title="Abrir conversa"
|
||||
@click="handleOpenConversation"
|
||||
>
|
||||
<i class="pi pi-comment" />
|
||||
<span>Conversa</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.payload?.deeplink"
|
||||
class="notif-quick-btn notif-quick-btn--primary"
|
||||
:title="item.payload?.actionLabel || 'Abrir'"
|
||||
@click="handleOpenDeeplink"
|
||||
>
|
||||
<span>{{ item.payload?.actionLabel || 'Abrir' }}</span>
|
||||
<i class="pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<!-- Ações secundárias (revelam no hover) -->
|
||||
<div class="notif-item__actions" @click.stop>
|
||||
<button v-if="isUnread" class="notif-item__btn" title="Marcar como lida" @click="handleMarkRead">
|
||||
<i class="pi pi-check" />
|
||||
@@ -212,142 +230,216 @@ function handleArchive(e) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Item-card: layout flat, mas com type-color como spine visual.
|
||||
--type-rgb é injetado inline (vem do typeMap). color-mix() pinta
|
||||
bg/border/icon usando a mesma cor — uma var, várias intensidades. */
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: transparent;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.85rem 0.75rem 0.95rem;
|
||||
margin: 0.4rem 0.6rem;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
/* Spine colorida: 3px na esquerda da cor do tipo. Reforça pertencimento
|
||||
sem usar background pesado. */
|
||||
.notif-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10%;
|
||||
bottom: 10%;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: rgb(var(--type-rgb));
|
||||
opacity: 0.55;
|
||||
transition: opacity 160ms ease, top 160ms ease, bottom 160ms ease;
|
||||
}
|
||||
.notif-item:hover {
|
||||
background: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, rgb(var(--type-rgb)) 12%, transparent);
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 40%, var(--surface-border));
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.notif-item:hover::before { opacity: 1; top: 0; bottom: 0; }
|
||||
.notif-item:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, rgb(var(--type-rgb)) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Não-lida: bg sutil tinted da cor do tipo + título mais forte */
|
||||
.notif-item--unread {
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 6%, var(--surface-card, #fff));
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 22%, var(--surface-border));
|
||||
}
|
||||
.notif-item--unread:hover {
|
||||
background: rgba(99, 102, 241, 0.09);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 9%, var(--surface-card, #fff));
|
||||
}
|
||||
|
||||
.notif-item__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.notif-item__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
/* Type avatar: círculo 40px com bg type-color 14% + ícone 100% */
|
||||
.notif-item__type {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #38bdf8);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 14%, transparent);
|
||||
color: rgb(var(--type-rgb));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-item__icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.notif-item__avatar {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(var(--type-rgb));
|
||||
}
|
||||
|
||||
/* Pulse dot pra "não lida" — fica no canto sup-direito do avatar */
|
||||
.notif-item__unread-dot {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--type-rgb));
|
||||
border: 2px solid var(--surface-card, #fff);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, rgb(var(--type-rgb)) 50%, transparent);
|
||||
animation: notif-unread-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes notif-unread-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, rgb(var(--type-rgb)) 50%, transparent); }
|
||||
50% { box-shadow: 0 0 0 5px color-mix(in srgb, rgb(var(--type-rgb)) 0%, transparent); }
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.notif-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notif-item__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.notif-item__type-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(var(--type-rgb));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.notif-item__time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-item__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
margin: 0 0 0.15rem;
|
||||
line-height: 1.3;
|
||||
/* 2 linhas máx */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notif-item--unread .notif-item__title { font-weight: 700; }
|
||||
.notif-item__detail {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notif-item__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.notif-item__time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Quick actions: chips type-color */
|
||||
.notif-item__quick {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.notif-quick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-color-secondary);
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--type-rgb)) 28%, var(--surface-border));
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 8%, transparent);
|
||||
color: rgb(var(--type-rgb));
|
||||
border-radius: 9999px;
|
||||
font-size: 0.68rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.notif-quick-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
border-color: var(--text-color-secondary);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 16%, transparent);
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 50%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.notif-quick-btn i {
|
||||
font-size: 0.65rem;
|
||||
.notif-quick-btn i { font-size: 0.7rem; }
|
||||
.notif-quick-btn--primary {
|
||||
background: rgb(var(--type-rgb));
|
||||
color: #fff;
|
||||
border-color: rgb(var(--type-rgb));
|
||||
}
|
||||
.notif-quick-btn--primary:hover {
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 88%, #000 12%);
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 88%, #000 12%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Ações secundárias (mark-read / archive): revelam no hover */
|
||||
.notif-item__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
transform: translateX(4px);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.notif-item:hover .notif-item__actions {
|
||||
.notif-item:hover .notif-item__actions,
|
||||
.notif-item:focus-within .notif-item__actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.notif-item__btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-100, var(--surface-hover, rgba(0,0,0,0.04)));
|
||||
color: var(--text-color-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
font-size: 0.7rem;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.notif-item__btn:hover {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, rgb(var(--type-rgb)) 12%, transparent);
|
||||
color: rgb(var(--type-rgb));
|
||||
border-color: color-mix(in srgb, rgb(var(--type-rgb)) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,18 +126,22 @@ watch(() => props.entityId, async (v) => {
|
||||
if (v) await api.loadEmails(props.entityType, v);
|
||||
else api.emails.value = [];
|
||||
});
|
||||
|
||||
// Re-emite `change` sempre que a lista mudar (load, add, edit, remove,
|
||||
// setPrimary). Permite que o parent trackee count e faça validação de
|
||||
// "pelo menos 1 email obrigatório". immediate:true garante emit no load.
|
||||
watch(api.emails, (arr) => emit('change', arr), { deep: true, immediate: true });
|
||||
|
||||
// Exposto pro parent — flush em lote dos emails pendentes (modo "novo
|
||||
// paciente" antes do save). Ver doc no useContactEmails.flushPending.
|
||||
async function flushPending(entityType, entityId) {
|
||||
return api.flushPending(entityType, entityId);
|
||||
}
|
||||
defineExpose({ flushPending });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Marque um email como <strong>principal</strong> — ele é usado pra
|
||||
<strong>envio de faturas, templates e notificações por email</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||||
</div>
|
||||
@@ -232,6 +236,7 @@ watch(() => props.entityId, async (v) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão + (sempre habilitado: sem entityId vai pro modo pendente) -->
|
||||
<Button
|
||||
v-if="!readonly && !showAddForm"
|
||||
label="Adicionar email"
|
||||
@@ -240,8 +245,6 @@ watch(() => props.entityId, async (v) => {
|
||||
outlined
|
||||
size="small"
|
||||
class="self-start rounded-full"
|
||||
:disabled="!props.entityId"
|
||||
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar emails' : null"
|
||||
@click="openAddForm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,20 +153,24 @@ watch(() => props.entityId, async (v) => {
|
||||
if (v) await api.loadPhones(props.entityType, v);
|
||||
else api.phones.value = [];
|
||||
});
|
||||
|
||||
// Re-emite `change` sempre que a lista mudar (load, add, edit, remove,
|
||||
// setPrimary). Permite que o parent trackee count e faça validação de
|
||||
// "pelo menos 1 telefone obrigatório" sem precisar inspeccionar o
|
||||
// componente. immediate:true garante emit no load inicial.
|
||||
watch(api.phones, (arr) => emit('change', arr), { deep: true, immediate: true });
|
||||
|
||||
// Exposto pro parent — usado pelo PatientsCadastroPage no fluxo de criação:
|
||||
// telefones inseridos antes de salvar o paciente ficam em modo pendente
|
||||
// (id: 'pending_*') e são gravados em lote depois que o paciente recebe id.
|
||||
async function flushPending(entityType, entityId) {
|
||||
return api.flushPending(entityType, entityId);
|
||||
}
|
||||
defineExpose({ flushPending });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Aviso sobre telefone principal -->
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Marque um telefone como <strong>principal</strong> — ele é usado pra
|
||||
<strong>cobranças, lembretes automáticos e contato padrão</strong>.
|
||||
Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista de telefones -->
|
||||
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||||
@@ -325,7 +329,7 @@ watch(() => props.entityId, async (v) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão + -->
|
||||
<!-- Botão + (sempre habilitado: sem entityId vai pro modo pendente) -->
|
||||
<Button
|
||||
v-if="!readonly && !showAddForm"
|
||||
label="Adicionar telefone"
|
||||
@@ -334,8 +338,6 @@ watch(() => props.entityId, async (v) => {
|
||||
outlined
|
||||
size="small"
|
||||
class="self-start rounded-full"
|
||||
:disabled="!props.entityId"
|
||||
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar telefones' : null"
|
||||
@click="openAddForm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,11 @@ async function onCreated(data) {
|
||||
:closable="false"
|
||||
:dismissableMask="false"
|
||||
:maximizable="false"
|
||||
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
|
||||
:style="{
|
||||
width: maximized ? '100vw' : '90vw',
|
||||
maxWidth: maximized ? 'none' : '1100px',
|
||||
height: maximized ? '100vh' : '90vh'
|
||||
}"
|
||||
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
|
||||
@@ -19,6 +19,14 @@ function normalizeEmail(raw) {
|
||||
return String(raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Mesma estratégia do useContactPhones: emails sem entidade ficam em
|
||||
// memória com id 'pending_*' até flushPending gravar tudo em lote.
|
||||
const PENDING_PREFIX = 'pending_';
|
||||
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
||||
function genPendingId() {
|
||||
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useContactEmails() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -77,12 +85,34 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = normalizeEmail(email);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
|
||||
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe — mantém em memória até flushPending.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = emails.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
emails.value.forEach((e) => { e.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
||||
const tempEmail = {
|
||||
id: genPendingId(),
|
||||
contact_email_type_id,
|
||||
email: clean,
|
||||
is_primary,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
emails.value = [...emails.value, tempEmail];
|
||||
return { ok: true, email: tempEmail };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (is_primary) {
|
||||
@@ -117,15 +147,28 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function updateEmail(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
|
||||
// Pending: muta no array local, sem DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = emails.value.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
emails.value.forEach((e, i) => { if (i !== idx) e.is_primary = false; });
|
||||
}
|
||||
emails.value[idx] = { ...emails.value[idx], ...sanitized };
|
||||
emails.value = [...emails.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -141,6 +184,19 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function removeEmail(entityType, entityId, id) {
|
||||
// Pending: tira do array + promove próximo a primary se necessário
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
emails.value = emails.value.filter((e) => e.id !== id);
|
||||
if (wasPrimary && emails.value.length > 0) {
|
||||
const remaining = [...emails.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
emails.value = emails.value.map((e) =>
|
||||
e.id === remaining[0].id ? { ...e, is_primary: true } : e
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
@@ -161,6 +217,36 @@ export function useContactEmails() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os emails que estavam em modo pendente.
|
||||
async function flushPending(entityType, entityId) {
|
||||
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
const pendingItems = emails.value.filter((e) => isPending(e.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((e) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id: e.contact_email_type_id,
|
||||
email: normalizeEmail(e.email),
|
||||
is_primary: !!e.is_primary,
|
||||
notes: e.notes || null,
|
||||
position: e.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_emails').insert(rows);
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'flush_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updateEmail(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -179,6 +265,7 @@ export function useContactEmails() {
|
||||
updateEmail,
|
||||
removeEmail,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
@@ -17,6 +17,15 @@ function normalizeDigits(raw) {
|
||||
return String(raw || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
// Telefones em "modo pendente" (entidade ainda não existe no DB) usam ID
|
||||
// com este prefixo. Permite reusar o mesmo array `phones` na UI sem
|
||||
// sub-state e detectar quais precisam de INSERT no flushPending.
|
||||
const PENDING_PREFIX = 'pending_';
|
||||
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
||||
function genPendingId() {
|
||||
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useContactPhones() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -76,12 +85,37 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const digits = normalizeDigits(number);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
|
||||
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe (ex: novo paciente sendo
|
||||
// cadastrado). Mantém em memória — flushPending grava tudo em lote
|
||||
// depois que a entidade for criada.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = phones.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
phones.value.forEach((p) => { p.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
|
||||
const tempPhone = {
|
||||
id: genPendingId(),
|
||||
contact_type_id,
|
||||
number: digits,
|
||||
is_primary,
|
||||
whatsapp_linked_at,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
phones.value = [...phones.value, tempPhone];
|
||||
return { ok: true, phone: tempPhone };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// Se marcou como primary, desmarca outros
|
||||
@@ -119,14 +153,26 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function updatePhone(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
// Pending: muta no array local sem ir pro DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = phones.value.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
phones.value.forEach((p, i) => { if (i !== idx) p.is_primary = false; });
|
||||
}
|
||||
phones.value[idx] = { ...phones.value[idx], ...sanitized };
|
||||
phones.value = [...phones.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -146,6 +192,19 @@ export function useContactPhones() {
|
||||
}
|
||||
|
||||
async function removePhone(entityType, entityId, id) {
|
||||
// Pending: tira do array local + promove o próximo a primary se sumiu
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
phones.value = phones.value.filter((p) => p.id !== id);
|
||||
if (wasPrimary && phones.value.length > 0) {
|
||||
const remaining = [...phones.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
phones.value = phones.value.map((p) =>
|
||||
p.id === remaining[0].id ? { ...p, is_primary: true } : p
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
@@ -171,6 +230,40 @@ export function useContactPhones() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os telefones que estavam em modo pendente. Chamado pelo
|
||||
// parent (ex: PatientsCadastroPage) logo depois de criar a entidade no DB.
|
||||
// Mantém ordem (position) e o flag is_primary do estado local.
|
||||
async function flushPending(entityType, entityId) {
|
||||
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
const pendingItems = phones.value.filter((p) => isPending(p.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((p) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_type_id: p.contact_type_id,
|
||||
number: normalizeDigits(p.number),
|
||||
is_primary: !!p.is_primary,
|
||||
whatsapp_linked_at: p.whatsapp_linked_at || null,
|
||||
notes: p.notes || null,
|
||||
position: p.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_phones').insert(rows);
|
||||
if (error) throw error;
|
||||
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
|
||||
await loadPhones(entityType, entityId);
|
||||
return { ok: true, count: rows.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'flush_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updatePhone(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -193,6 +286,7 @@ export function useContactPhones() {
|
||||
updatePhone,
|
||||
removePhone,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* useTopbarPlanMenu — DEV-only switcher de subscription_plan no topbar.
|
||||
*
|
||||
* Extraído do AppTopbar.vue pra ser reusado pelo Melissa (e qualquer
|
||||
* topbar futuro). Encapsula toda a máquina de estados:
|
||||
* - resolve subscription ativa do contexto (clinic vs therapist)
|
||||
* - lista plans ativos do target
|
||||
* - ordena (free primeiro)
|
||||
* - troca via RPC `change_subscription_plan` + invalida entitlements
|
||||
*
|
||||
* Visibilidade controlada por `showPlanDevMenu` (DEV mode + feature flag
|
||||
* `VITE_ENABLE_PLAN_TOGGLE` + permissão settings.view).
|
||||
*
|
||||
* Uso:
|
||||
* const { planBtn, planMenu, planMenuModel, planMenuLoading,
|
||||
* trocandoPlano, showPlanDevMenu, openPlanMenu } = useTopbarPlanMenu();
|
||||
*
|
||||
* <Button v-if="showPlanDevMenu" ref="planBtn" :loading="planMenuLoading || trocandoPlano" @click="openPlanMenu">…</Button>
|
||||
* <Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" />
|
||||
*/
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard';
|
||||
|
||||
export function useTopbarPlanMenu() {
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const entitlementsStore = useEntitlementsStore();
|
||||
const { canSee } = useRoleGuard();
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null);
|
||||
|
||||
const planBtn = ref(null);
|
||||
const planMenu = ref(null);
|
||||
const planMenuLoading = ref(false);
|
||||
const planMenuTarget = ref(null); // 'therapist' | 'clinic' | null
|
||||
const planMenuSub = ref(null); // subscription ativa
|
||||
const planMenuPlans = ref([]); // plans ativos do target
|
||||
const trocandoPlano = ref(false);
|
||||
|
||||
const enablePlanToggle = computed(() => {
|
||||
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase();
|
||||
return Boolean(import.meta.env?.DEV) || flag === 'true';
|
||||
});
|
||||
|
||||
const showPlanDevMenu = computed(() => canSee('settings.view') && enablePlanToggle.value);
|
||||
|
||||
async function getMyUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida (sem user).');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function getActiveTherapistSubscription() {
|
||||
const uid = await getMyUserId();
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('user_id', uid)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10);
|
||||
if (error) throw error;
|
||||
const list = data || [];
|
||||
if (!list.length) return null;
|
||||
const priority = (st) => {
|
||||
const s = String(st || '').toLowerCase();
|
||||
if (s === 'active') return 1;
|
||||
if (s === 'trialing') return 2;
|
||||
if (s === 'past_due') return 3;
|
||||
if (s === 'unpaid') return 4;
|
||||
if (s === 'incomplete') return 5;
|
||||
if (s === 'canceled' || s === 'cancelled') return 9;
|
||||
return 8;
|
||||
};
|
||||
return list.slice().sort((a, b) => {
|
||||
const pa = priority(a?.status);
|
||||
const pb = priority(b?.status);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0);
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function getActiveClinicSubscription() {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return null;
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, user_id, plan_id, status, updated_at')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
async function listActivePlansByTarget(target) {
|
||||
const { data, error } = await supabase
|
||||
.from('plans')
|
||||
.select('id, key, target, is_active')
|
||||
.eq('target', target)
|
||||
.eq('is_active', true)
|
||||
.order('key', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
async function refreshEntitlementsAfterToggle(target) {
|
||||
if (target === 'clinic') {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return;
|
||||
await entitlementsStore.loadForTenant(tid, { force: true });
|
||||
return;
|
||||
}
|
||||
const uid = await getMyUserId();
|
||||
await entitlementsStore.loadForUser(uid, { force: true });
|
||||
}
|
||||
|
||||
// Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic).
|
||||
// Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id.
|
||||
// Em /melissa não há um caminho semântico de área, então cai no fallback
|
||||
// therapist (que é o role mais comum do user que escolhe Melissa).
|
||||
async function resolveActiveSubscriptionContext() {
|
||||
const path = route.path || '';
|
||||
const isClinicContext = path.startsWith('/admin') || path.startsWith('/supervisor');
|
||||
|
||||
if (isClinicContext && tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription();
|
||||
if (clinicSub) return { sub: clinicSub, target: 'clinic' };
|
||||
}
|
||||
|
||||
const therapistSub = await getActiveTherapistSubscription();
|
||||
if (therapistSub) return { sub: therapistSub, target: 'therapist' };
|
||||
|
||||
if (tenantId.value) {
|
||||
const clinicSub = await getActiveClinicSubscription();
|
||||
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null };
|
||||
}
|
||||
return { sub: null, target: null };
|
||||
}
|
||||
|
||||
function normalizeKey(k) {
|
||||
return String(k || '').trim();
|
||||
}
|
||||
|
||||
// free primeiro, depois o resto por key
|
||||
function sortPlansSmart(plans) {
|
||||
const arr = [...(plans || [])];
|
||||
arr.sort((a, b) => {
|
||||
const ak = normalizeKey(a?.key).toLowerCase();
|
||||
const bk = normalizeKey(b?.key).toLowerCase();
|
||||
const aIsFree = ak.endsWith('_free') || ak === 'free';
|
||||
const bIsFree = bk.endsWith('_free') || bk === 'free';
|
||||
if (aIsFree && !bIsFree) return -1;
|
||||
if (!aIsFree && bIsFree) return 1;
|
||||
return ak.localeCompare(bk);
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
|
||||
async function loadPlanMenuData() {
|
||||
planMenuLoading.value = true;
|
||||
try {
|
||||
const { sub, target } = await resolveActiveSubscriptionContext();
|
||||
planMenuSub.value = sub;
|
||||
planMenuTarget.value = target;
|
||||
if (!sub?.id || !target) {
|
||||
planMenuPlans.value = [];
|
||||
return;
|
||||
}
|
||||
const plans = await listActivePlansByTarget(target);
|
||||
planMenuPlans.value = sortPlansSmart(plans);
|
||||
} finally {
|
||||
planMenuLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const planMenuModel = computed(() => {
|
||||
const sub = planMenuSub.value;
|
||||
const target = planMenuTarget.value;
|
||||
const plans = planMenuPlans.value || [];
|
||||
|
||||
if (!sub?.id || !target) {
|
||||
return [
|
||||
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
|
||||
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
|
||||
];
|
||||
}
|
||||
|
||||
const currentPlanId = String(sub.plan_id || '');
|
||||
|
||||
const header = {
|
||||
label: `Planos (${target})`,
|
||||
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
const subInfo = {
|
||||
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}…`,
|
||||
icon: 'pi pi-info-circle',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
const items = [];
|
||||
let insertedSeparator = false;
|
||||
|
||||
plans.forEach((p) => {
|
||||
const isCurrent = String(p.id) === currentPlanId;
|
||||
const keyLower = String(p.key || '').toLowerCase();
|
||||
const isFree = keyLower.endsWith('_free') || keyLower === 'free';
|
||||
|
||||
items.push({
|
||||
label: isCurrent ? `${p.key} (atual)` : p.key,
|
||||
icon: isCurrent ? 'pi pi-check' : isFree ? 'pi pi-star' : 'pi pi-circle',
|
||||
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
|
||||
command: async () => {
|
||||
await changePlanTo(p.id, p.key, target);
|
||||
}
|
||||
});
|
||||
|
||||
if (!insertedSeparator && isFree) {
|
||||
items.push({ separator: true });
|
||||
insertedSeparator = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length && items[items.length - 1]?.separator) items.pop();
|
||||
|
||||
if (!plans.length) {
|
||||
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }];
|
||||
}
|
||||
|
||||
return [header, subInfo, { separator: true }, ...items];
|
||||
});
|
||||
|
||||
async function openPlanMenu(event) {
|
||||
if (!showPlanDevMenu.value) return;
|
||||
|
||||
// Captura a âncora ANTES do await — `event.currentTarget` é null
|
||||
// depois que a microtask resume (DOM behavior). Suporta tanto
|
||||
// PrimeVue <Button> (expõe `$el`) quanto <button> HTML cru
|
||||
// (planBtn.value já é o DOM element).
|
||||
const anchorEl =
|
||||
planBtn.value?.$el ||
|
||||
planBtn.value ||
|
||||
event?.currentTarget ||
|
||||
event?.target;
|
||||
|
||||
try {
|
||||
await loadPlanMenuData();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PLANO][DEV menu] erro:', err?.message || err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao carregar planos',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 5200
|
||||
});
|
||||
}
|
||||
|
||||
if (!anchorEl) {
|
||||
planMenu.value?.toggle?.(event);
|
||||
return;
|
||||
}
|
||||
planMenu.value?.show?.({ currentTarget: anchorEl });
|
||||
}
|
||||
|
||||
async function changePlanTo(newPlanId, newPlanKey, target) {
|
||||
if (trocandoPlano.value) return;
|
||||
trocandoPlano.value = true;
|
||||
try {
|
||||
const sub = planMenuSub.value;
|
||||
if (!sub?.id) throw new Error('Subscription inválida.');
|
||||
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: sub.id,
|
||||
p_new_plan_id: newPlanId
|
||||
});
|
||||
if (rpcError) throw rpcError;
|
||||
planMenuSub.value = { ...sub, plan_id: newPlanId };
|
||||
await refreshEntitlementsAfterToggle(target);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano alterado (DEV)',
|
||||
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
|
||||
life: 3200
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PLANO] Erro ao trocar:', err?.message || err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao trocar plano',
|
||||
detail: err?.message || 'Falha desconhecida.',
|
||||
life: 6000
|
||||
});
|
||||
} finally {
|
||||
trocandoPlano.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planBtn,
|
||||
planMenu,
|
||||
planMenuModel,
|
||||
planMenuLoading,
|
||||
trocandoPlano,
|
||||
showPlanDevMenu,
|
||||
openPlanMenu
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
@@ -37,6 +38,14 @@ const emit = defineEmits(['bloqueado']);
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
const toast = useToast();
|
||||
const { layoutConfig } = useLayout();
|
||||
|
||||
// Quando o layout ativo é Melissa, "Ver todos os feriados" leva pra rota
|
||||
// interna /melissa/bloqueios (abre MelissaConfiguracoes na seção embed
|
||||
// de Bloqueios). Caso contrário usa a rota tradicional de configurações.
|
||||
const verTodosFeriadosRoute = computed(() =>
|
||||
layoutConfig.variant === 'melissa' ? '/melissa/bloqueios' : '/configuracoes/bloqueios'
|
||||
);
|
||||
|
||||
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados();
|
||||
|
||||
@@ -236,7 +245,7 @@ function fmtDate(iso) {
|
||||
<i class="pi pi-star text-amber-500 text-sm" />
|
||||
<span class="font-semibold text-sm">Próximos feriados</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
|
||||
<span class="pfc-month-badge">{{ nomeMes }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
@@ -282,12 +291,12 @@ function fmtDate(iso) {
|
||||
<!-- Confirmação inline (expande abaixo do item) -->
|
||||
<Transition name="pfc-expand">
|
||||
<div v-if="confirmandoIso === f.data" class="pfc-confirm">
|
||||
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
|
||||
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
|
||||
</div>
|
||||
<div class="flex gap-1.5 shrink-0">
|
||||
<p class="text-xs font-semibold mb-0.5">
|
||||
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
|
||||
Bloquear {{ f.nome }}?
|
||||
</p>
|
||||
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
|
||||
<div class="pfc-confirm__actions flex gap-1.5">
|
||||
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
|
||||
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
|
||||
</div>
|
||||
@@ -300,7 +309,7 @@ function fmtDate(iso) {
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col gap-1.5 px-4 pb-4">
|
||||
<Button icon="pi pi-plus" label="Cadastrar feriado municipal" severity="secondary" outlined size="small" class="w-full rounded-full" @click="abrirDialog" />
|
||||
<Button icon="pi pi-list" label="Ver todos os feriados" text size="small" class="w-full rounded-full" @click="router.push('/configuracoes/bloqueios')" />
|
||||
<Button icon="pi pi-list" label="Ver todos os feriados" text size="small" class="w-full rounded-full" @click="router.push(verTodosFeriadosRoute)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -370,20 +379,38 @@ function fmtDate(iso) {
|
||||
|
||||
/* ── Confirmação inline ───────────────────────────────────── */
|
||||
.pfc-confirm {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
/* sem display:flex — texto flui em bloco; botões ganham margin-top
|
||||
pra distanciar do parágrafo. */
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
|
||||
margin-left: 2.75rem; /* alinha com o nome, após a data */
|
||||
}
|
||||
.pfc-confirm__icon {
|
||||
color: var(--red-500, #ef4444);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
margin-right: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Espaço entre os botões "Não/Bloquear" e o texto acima. */
|
||||
.pfc-confirm__actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ── Mês atual no header (badge primary) ─────────────────── */
|
||||
.pfc-month-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--p-primary-color);
|
||||
color: var(--p-primary-contrast-color, white);
|
||||
border: 1px solid var(--p-primary-color);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: capitalize;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Transição expand ─────────────────────────────────────── */
|
||||
|
||||
@@ -310,6 +310,16 @@ const navPopover = ref(null)
|
||||
const isCompact = ref(false)
|
||||
let mql = null, mqlCb = null
|
||||
|
||||
// View mode: 'vertical' (Accordion) | 'horizontal' (Tabs)
|
||||
const VIEW_MODE_KEY = 'pcd.viewMode.v1'
|
||||
const viewMode = ref('vertical')
|
||||
try {
|
||||
const saved = localStorage.getItem(VIEW_MODE_KEY)
|
||||
if (saved === 'vertical' || saved === 'horizontal') viewMode.value = saved
|
||||
} catch (_) {}
|
||||
watch(viewMode, (v) => { try { localStorage.setItem(VIEW_MODE_KEY, v) } catch (_) {} })
|
||||
function setViewMode (m) { if (m === 'vertical' || m === 'horizontal') viewMode.value = m }
|
||||
|
||||
function syncCompact () { isCompact.value = !!mql?.matches }
|
||||
function toggleNav (e) { navPopover.value?.toggle(e) }
|
||||
function selectNav (s) { openPanel(Number(s.value)); navPopover.value?.hide() }
|
||||
@@ -563,6 +573,19 @@ async function onCepBlur () {
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
// `submitted` é true depois da primeira tentativa de salvar — usado pra
|
||||
// mostrar borda vermelha + msg "Campo obrigatório" embaixo dos inputs
|
||||
// sem incomodar o usuário antes da primeira interação.
|
||||
const submitted = ref(false)
|
||||
// Counts dos editores polimórficos (telefones/emails) — atualizados via
|
||||
// @change. Telefone e email são obrigatórios: pelo menos 1 cada.
|
||||
const phonesCount = ref(0)
|
||||
const emailsCount = ref(0)
|
||||
// Refs pros editores — usados pra chamar `flushPending` depois que o
|
||||
// paciente é criado (telefones/emails inseridos antes do save ficam
|
||||
// em modo pendente até a entidade existir no DB).
|
||||
const phonesEditorRef = ref(null)
|
||||
const emailsEditorRef = ref(null)
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
@@ -600,6 +623,10 @@ watch(patientId, fetchAll, { immediate:true })
|
||||
// Submit
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function onSubmit () {
|
||||
// Marca pra que :invalid + mensagens de erro fiquem visíveis nos inputs
|
||||
// exigidos. Reseta no sucesso (logo abaixo) ou na próxima edição válida
|
||||
// (não reseta automaticamente — só atrapalharia o feedback visual).
|
||||
submitted.value = true
|
||||
saving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
@@ -609,6 +636,16 @@ async function onSubmit () {
|
||||
toast.add({ severity:'warn', summary:'Nome obrigatório', detail:'Preencha o nome completo.', life:3500 })
|
||||
await openPanel(0); return
|
||||
}
|
||||
// Telefone e email são obrigatórios: pelo menos 1 cada. Toast aponta
|
||||
// pro campo faltando + abre a seção Identidade (onde os editores ficam).
|
||||
if (phonesCount.value === 0) {
|
||||
toast.add({ severity:'warn', summary:'Telefone obrigatório', detail:'Adicione pelo menos um telefone.', life:3500 })
|
||||
await openPanel(0); return
|
||||
}
|
||||
if (emailsCount.value === 0) {
|
||||
toast.add({ severity:'warn', summary:'E-mail obrigatório', detail:'Adicione pelo menos um e-mail.', life:3500 })
|
||||
await openPanel(0); return
|
||||
}
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
|
||||
if (isEdit.value) {
|
||||
@@ -618,15 +655,23 @@ async function onSubmit () {
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
await saveContatosSuporte(patientId.value, tenantId, ownerId)
|
||||
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente atualizado.', life:2500 })
|
||||
submitted.value = false
|
||||
if (props.dialogMode) { emit('created', { id:patientId.value }); return }
|
||||
return
|
||||
}
|
||||
const created = await createPatient(payload)
|
||||
// Telefones/emails podem ter sido adicionados ANTES do paciente existir
|
||||
// (modo pendente — id 'pending_*' em memória). Agora que temos `created.id`,
|
||||
// gravamos tudo em lote no DB. Roda antes de avatar/grupos/tags pra que
|
||||
// qualquer falha aqui aborte o resto do fluxo.
|
||||
await phonesEditorRef.value?.flushPending('patient', created.id)
|
||||
await emailsEditorRef.value?.flushPending('patient', created.id)
|
||||
await maybeUploadAvatar(ownerId, created.id)
|
||||
await replacePatientGroups(created.id, grupoIdSelecionado.value)
|
||||
await replacePatientTags(created.id, tagIdsSelecionadas.value)
|
||||
await saveContatosSuporte(created.id, tenantId, ownerId)
|
||||
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente cadastrado.', life:2500 })
|
||||
submitted.value = false
|
||||
if (props.dialogMode) { emit('created', created); return }
|
||||
form.value=resetForm(); grupoIdSelecionado.value=null; tagIdsSelecionadas.value=[]
|
||||
contatosSuporte.value=[]; avatarFile.value=null; revokePreview(); avatarPreviewUrl.value=''
|
||||
@@ -1014,7 +1059,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<i class="pi pi-spin pi-spinner text-xl" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] max-w-[1040px] mx-auto">
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] xl:items-start max-w-[1040px] mx-auto">
|
||||
|
||||
<!-- ── SIDEBAR ────────────────────────────────────── -->
|
||||
<aside
|
||||
@@ -1044,8 +1089,32 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav desktop (≥ xl) -->
|
||||
<div v-if="!isCompact" class="flex flex-col gap-0.5">
|
||||
<!-- Toggle layout vertical/horizontal -->
|
||||
<div class="flex items-center gap-1 mb-3 p-0.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
|
||||
:class="viewMode === 'vertical' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
title="Layout vertical (acordeão)"
|
||||
@click="setViewMode('vertical')"
|
||||
>
|
||||
<i class="pi pi-bars text-[0.68rem]" />
|
||||
<span>Vertical</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
|
||||
:class="viewMode === 'horizontal' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
title="Layout horizontal (abas)"
|
||||
@click="setViewMode('horizontal')"
|
||||
>
|
||||
<i class="pi pi-th-large text-[0.68rem] rotate-90" />
|
||||
<span>Abas</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nav desktop (≥ xl) — só em vertical (em horizontal as tabs ficam acima do form) -->
|
||||
<div v-if="!isCompact && viewMode === 'vertical'" class="flex flex-col gap-0.5">
|
||||
<div class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2 mb-1">Seções</div>
|
||||
<button
|
||||
v-for="s in sections" :key="s.value" type="button"
|
||||
@@ -1080,8 +1149,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav compacto (< xl) -->
|
||||
<div v-if="isCompact">
|
||||
<!-- Nav compacto (< xl) — só em vertical -->
|
||||
<div v-if="isCompact && viewMode === 'vertical'">
|
||||
<Button
|
||||
type="button" class="w-full !rounded-full"
|
||||
icon="pi pi-list" iconPos="right"
|
||||
@@ -1155,7 +1224,39 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN ───────────────────────────────────────── -->
|
||||
<main class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
|
||||
<main
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm"
|
||||
:class="{ 'pcd-horizontal': viewMode === 'horizontal' }"
|
||||
>
|
||||
<!-- Tab list (só em horizontal) -->
|
||||
<div
|
||||
v-if="viewMode === 'horizontal'"
|
||||
class="flex gap-0.5 overflow-x-auto px-2 pt-2 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]/40"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="s in sections" :key="s.value"
|
||||
type="button" role="tab"
|
||||
:aria-selected="activeValue === s.value"
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-t-lg text-[0.78rem] font-medium border-b-2 transition-all duration-150 shrink-0 whitespace-nowrap"
|
||||
:class="activeValue === s.value
|
||||
? `${pal[s.accent].activeBtn} !rounded-b-none`
|
||||
: 'text-[var(--text-color-secondary)] border-transparent hover:bg-[var(--surface-card)]/60 hover:text-[var(--text-color)]'"
|
||||
@click="activeValue = s.value"
|
||||
>
|
||||
<span class="flex items-center justify-center w-5 h-5 rounded-md text-[0.62rem] shrink-0" :class="pal[s.accent].iconBox">
|
||||
<i :class="s.icon"/>
|
||||
</span>
|
||||
<span>{{ s.label }}</span>
|
||||
<i v-if="p(s.value).filled === p(s.value).total"
|
||||
class="pi pi-check-circle text-emerald-500 text-[0.7rem] shrink-0" />
|
||||
<span v-else-if="p(s.value).filled > 0"
|
||||
class="text-[0.6rem] text-amber-600 font-bold shrink-0">
|
||||
{{ p(s.value).filled }}/{{ p(s.value).total }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Accordion :multiple="false" v-model:value="activeValue">
|
||||
|
||||
<!-- ╔═══════════════════════════════════════════╗
|
||||
@@ -1177,22 +1278,37 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
|
||||
<!-- Nome & identidade -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
|
||||
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
|
||||
|
||||
<!-- Nome completo — full width -->
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-user"/><InputText id="f_nome" v-model="form.nome_completo" class="w-full" variant="filled"/></IconField>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user"/>
|
||||
<InputText
|
||||
id="f_nome"
|
||||
v-model="form.nome_completo"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:invalid="submitted && !String(form.nome_completo || '').trim()"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="f_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido no header do perfil do paciente.</div>
|
||||
<small
|
||||
v-if="submitted && !String(form.nome_completo || '').trim()"
|
||||
class="mt-2 text-[0.85rem] text-red-500 flex items-center gap-1.5"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
|
||||
<span>Campo obrigatório.</span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Nome social -->
|
||||
@@ -1201,7 +1317,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<InputText id="f_nome_social" v-model="form.nome_social" class="w-full" variant="filled"/>
|
||||
<label for="f_nome_social">Nome social</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → como prefere ser chamado(a).</div>
|
||||
</div>
|
||||
|
||||
<!-- Pronomes -->
|
||||
@@ -1210,16 +1325,18 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_pronomes" v-model="form.pronomes" :options="pronounsOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_pronomes">Pronomes</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Header: <em>"32 anos · <strong>ela/dela</strong> · São Carlos, SP"</em></div>
|
||||
</div>
|
||||
|
||||
<!-- Data de nascimento -->
|
||||
<!-- Data de nascimento — InputGroup com idade calculada como addon à direita -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-calendar"/><InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-calendar"/></InputGroupAddon>
|
||||
<InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" variant="filled"/>
|
||||
<InputGroupAddon v-if="ageLabel !== '—'" class="font-semibold text-[var(--primary-color)]">{{ ageLabel }}</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<label for="f_nasc">Data de nascimento</label>
|
||||
</FloatLabel>
|
||||
<div v-if="ageLabel!=='—'" class="mt-1 text-[0.63rem] text-indigo-600 font-semibold"><i class="pi pi-info-circle mr-1"/>{{ ageLabel }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Gênero -->
|
||||
@@ -1244,7 +1361,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-id-card"/><InputMask id="f_cpf" v-model="form.cpf" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_cpf">CPF</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido mascarado: <em>••••456••••90</em></div>
|
||||
</div>
|
||||
|
||||
<!-- RG -->
|
||||
@@ -1261,7 +1377,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_etnia" v-model="form.etnia" :options="etniaOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_etnia">Etnia / raça</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → linha "Etnia".</div>
|
||||
</div>
|
||||
|
||||
<!-- Naturalidade -->
|
||||
@@ -1278,7 +1393,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-briefcase"/><InputText id="f_prof" v-model="form.profissao" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_prof">Profissão</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → "Desenvolvedora".</div>
|
||||
</div>
|
||||
|
||||
<!-- Escolaridade -->
|
||||
@@ -1287,41 +1401,91 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_esc" v-model="form.escolaridade" :options="escolaridadeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_esc">Escolaridade</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" → "Superior completo".</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contato — alimenta card "Contato" -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
|
||||
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.indigo.hint">Card "Contato" no detalhe</span>
|
||||
</div>
|
||||
<!-- Telefones (polimórfico — tipo/número/principal/vinculado) -->
|
||||
<div class="col-span-full">
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
|
||||
<i class="pi pi-phone text-[var(--primary-color)]" />
|
||||
Telefones
|
||||
<div class="col-span-full mb-7">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
|
||||
:class="submitted && phonesCount === 0
|
||||
? 'border-red-300 bg-red-50/60 text-red-700'
|
||||
: pal.indigo.infoBox"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
|
||||
:class="submitted && phonesCount === 0
|
||||
? 'bg-red-100 text-red-600'
|
||||
: pal.indigo.iconBox"
|
||||
>
|
||||
<i class="pi pi-phone text-[0.95rem]"/>
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[0.95rem] font-semibold leading-tight">Telefones *</div>
|
||||
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
|
||||
Marque um telefone como <strong>principal</strong> — ele é usado pra cobranças, lembretes automáticos e contato padrão. Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
|
||||
</div>
|
||||
<div
|
||||
v-if="submitted && phonesCount === 0"
|
||||
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
|
||||
<span>Adicione pelo menos um telefone.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContactPhonesEditor
|
||||
ref="phonesEditorRef"
|
||||
entity-type="patient"
|
||||
:entity-id="patientId || null"
|
||||
@change="(arr) => phonesCount = (arr || []).length"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Emails (polimórfico — tipo/endereço/principal) -->
|
||||
<div class="col-span-full">
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
|
||||
<i class="pi pi-envelope text-[var(--primary-color)]" />
|
||||
Emails
|
||||
<div class="col-span-full mb-7">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
|
||||
:class="submitted && emailsCount === 0
|
||||
? 'border-red-300 bg-red-50/60 text-red-700'
|
||||
: pal.indigo.infoBox"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
|
||||
:class="submitted && emailsCount === 0
|
||||
? 'bg-red-100 text-red-600'
|
||||
: pal.indigo.iconBox"
|
||||
>
|
||||
<i class="pi pi-envelope text-[0.95rem]"/>
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[0.95rem] font-semibold leading-tight">E-mails *</div>
|
||||
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
|
||||
Marque um e-mail como <strong>principal</strong> — ele é usado pra envio de recibos, comprovantes e comunicações oficiais.
|
||||
</div>
|
||||
<div
|
||||
v-if="submitted && emailsCount === 0"
|
||||
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
|
||||
<span>Adicione pelo menos um e-mail.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContactEmailsEditor
|
||||
ref="emailsEditorRef"
|
||||
entity-type="patient"
|
||||
:entity-id="patientId || null"
|
||||
@change="(arr) => emailsCount = (arr || []).length"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
|
||||
<!-- Canal preferido -->
|
||||
<div>
|
||||
@@ -1329,7 +1493,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Select id="f_canal" v-model="form.canal_preferido" :options="canalOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_canal">Canal preferido de contato</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato → "Canal preferido: <strong>WhatsApp</strong>".</div>
|
||||
</div>
|
||||
|
||||
<!-- Horário de contato -->
|
||||
@@ -1338,7 +1501,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-clock"/><InputText id="f_horario" v-model="form.horario_contato" class="w-full" variant="filled" placeholder="Ex: 08h–18h"/></IconField>
|
||||
<label for="f_horario">Horário de contato</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato → "Horário: <strong>08h–18h</strong>".</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações de endereço -->
|
||||
@@ -1347,7 +1509,10 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<Textarea id="f_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
|
||||
<label for="f_obs">Observações de endereço</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Ex: Próximo ao posto, portão azul, sem interfone.</div>
|
||||
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
|
||||
<i class="pi pi-info-circle text-[0.78rem]"/>
|
||||
<span>Ex: Próximo ao posto, portão azul, sem interfone.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1372,18 +1537,17 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.teal.infoBox}`">
|
||||
<i class="pi pi-lightbulb mt-0.5 shrink-0"/>
|
||||
<span>Digite o CEP e cidade, estado, bairro e logradouro são preenchidos automaticamente via <strong>ViaCEP</strong>.</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-map-marker"/><InputText id="f_cep" v-model="form.cep" class="w-full" @blur="onCepBlur" variant="filled" placeholder="00000-000"/></IconField>
|
||||
<label for="f_cep">CEP</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Card Contato → <em>"13560-000 · São Carlos"</em></div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1396,14 +1560,12 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<IconField><InputIcon class="pi pi-building"/><InputText id="f_city" v-model="form.cidade" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_city">Cidade</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header → <em>"<strong>São Carlos</strong>, SP"</em></div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-compass"/><InputText id="f_uf" v-model="form.estado" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_uf">Estado (UF)</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header → "São Carlos, <strong>SP</strong>"</div>
|
||||
</div>
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
@@ -1453,7 +1615,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
|
||||
<!-- Preview dos badges ao vivo -->
|
||||
<div v-if="form.status||convenioNome||form.patient_scope||tagIdsSelecionadas.length"
|
||||
@@ -1472,18 +1634,16 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
|
||||
<!-- Situação clínica -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
|
||||
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.violet.hint">Badges no header do perfil</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-3 mb-6">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3 mb-7">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select id="f_status" v-model="form.status" :options="statusOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_status">Status</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-green-600">verde</span> no header.</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- CONVÊNIO — seleciona de insurance_plans, máx 1 -->
|
||||
@@ -1517,33 +1677,33 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
@click="showConvenioDlg = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-blue-500">azul</span> no header · máx 1 convênio.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select id="f_scope" v-model="form.patient_scope" :options="scopeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
|
||||
<label for="f_scope">Escopo de atendimento</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-gray-500">cinza</span> no header.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organização: grupo + tags -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
|
||||
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.violet.hint">Chips coloridos no header</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-folder-open"/>
|
||||
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="nome" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
|
||||
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="name" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
|
||||
</IconField>
|
||||
<label for="f_grupo">Grupo</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Define o modelo de anamnese.</div>
|
||||
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
|
||||
<i class="pi pi-info-circle text-[0.78rem]"/>
|
||||
<span>Define o modelo de anamnese aplicado ao paciente.</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar grupo" @click="openGroupDlg"/>
|
||||
</div>
|
||||
@@ -1555,25 +1715,22 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</IconField>
|
||||
<label for="f_tags">Tags clínicas</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Aparecem como chips coloridos no header do perfil.</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar tag" @click="openTagDlg"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Origem — alimenta card "Origem" do detalhe -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
|
||||
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.violet.hint">Card "Origem" no perfil</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-megaphone"/><InputText id="f_lead" v-model="form.onde_nos_conheceu" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_lead">Como chegou até mim?</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem → "Como chegou: <strong>Indicação</strong>".</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- ENCAMINHADO POR — múltiplos médicos -->
|
||||
@@ -1623,14 +1780,16 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
class="rounded-full w-full"
|
||||
@click="showMedicoDlg = true"
|
||||
/>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Pode adicionar mais de um profissional de referência.</div>
|
||||
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
|
||||
<i class="pi pi-info-circle text-[0.78rem]"/>
|
||||
<span>Você pode adicionar mais de um profissional de referência.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-sign-out"/><InputText id="f_saida" v-model="form.motivo_saida" class="w-full" variant="filled" placeholder="Se aplicável"/></IconField>
|
||||
<label for="f_saida">Motivo de saída</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem → "Motivo de saída" quando preenchido.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1659,7 +1818,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.amber.infoBox}`">
|
||||
<i class="pi pi-info-circle mt-0.5 shrink-0"/>
|
||||
<span>Cada contato aqui aparece no card <strong>"Contatos & rede de suporte"</strong> do perfil. O marcado como <strong>emergência primária</strong> recebe badge vermelho.</span>
|
||||
@@ -1699,7 +1858,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<InputText :id="`cr_${idx}`" v-model="c.relacao" class="w-full" variant="filled" placeholder="Ex: mãe, psiquiatra"/>
|
||||
<label :for="`cr_${idx}`">Relação / papel</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Subtítulo no card: "Maria Lima · <strong>mãe</strong>".</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1714,7 +1872,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</IconField>
|
||||
<label :for="`ctel_${idx}`">Telefone</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido abaixo do nome no card.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1723,7 +1880,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</IconField>
|
||||
<label :for="`cemail_${idx}`">E-mail</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido ao lado do telefone.</div>
|
||||
</div>
|
||||
<!-- Emergência primária -->
|
||||
<div class="xl:col-span-2">
|
||||
@@ -1767,8 +1923,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div class="xl:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-user"/><InputText id="f_rn" v-model="form.nome_responsavel" class="w-full" variant="filled"/></IconField>
|
||||
@@ -1826,7 +1982,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.rose.infoBox}`">
|
||||
<i class="pi pi-shield mt-0.5 shrink-0"/>
|
||||
<span>Campo interno: <strong>não aparece</strong> no cadastro externo nem é compartilhado com o paciente.</span>
|
||||
@@ -1861,9 +2017,9 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
@@ -1918,9 +2074,9 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
@@ -2015,3 +2171,24 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Em modo horizontal, esconde os headers do Accordion (a navegação fica nas tabs em cima do main) */
|
||||
.pcd-horizontal :deep(.p-accordionheader) {
|
||||
display: none !important;
|
||||
}
|
||||
.pcd-horizontal :deep(.p-accordion-header) {
|
||||
display: none !important;
|
||||
}
|
||||
.pcd-horizontal :deep(.p-accordioncontent),
|
||||
.pcd-horizontal :deep(.p-accordion-content) {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Tira o padding interno do wrapper do AccordionContent — o conteúdo já tem
|
||||
o próprio padding (.p-5) por seção, então o do PrimeVue duplicava o
|
||||
espaçamento e dava sensação de elementos descolados. */
|
||||
:deep(.p-accordioncontent-content) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -688,9 +688,9 @@ function isRecent(row) {
|
||||
maximizable
|
||||
class="w-[96vw] max-w-2xl"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
|
||||
@@ -368,11 +368,15 @@ function onSearchFocus() {
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<!-- type="text" (não "search"): o nativo "search" injeta
|
||||
um X de "clear" do navegador que duplicava o botão
|
||||
custom logo abaixo. `inputmode="search"` mantém o
|
||||
teclado correto em mobile sem trazer o X nativo. -->
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="menu_search"
|
||||
name="menu_search"
|
||||
type="search"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
|
||||
@@ -158,7 +158,7 @@ function toggleUserMenu(e) {
|
||||
@click="selectHome"
|
||||
@mouseenter="onHomeHover"
|
||||
>
|
||||
<i class="pi pi-fw pi-home" />
|
||||
<i class="pi pi-fw pi-home text-[var(--primary-color)]" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -170,7 +170,7 @@ function toggleUserMenu(e) {
|
||||
@click="selectSection(section)"
|
||||
@mouseenter="onSectionHover(section)"
|
||||
>
|
||||
<i :class="section.icon" />
|
||||
<i :class="section.icon" class="text-[var(--primary-color)]" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -181,7 +181,7 @@ function toggleUserMenu(e) {
|
||||
aria-label="Configurações"
|
||||
@click="$router.push('/configuracoes')"
|
||||
>
|
||||
<i class="pi pi-fw pi-cog" />
|
||||
<i class="pi pi-fw pi-cog text-[var(--primary-color)]" />
|
||||
</button>
|
||||
|
||||
<!-- Avatar — trigger do menu de usuário -->
|
||||
|
||||
@@ -448,7 +448,7 @@ async function goToResult(r) {
|
||||
}"
|
||||
@click="navigate(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75 !text-[var(--primary-color)]" />
|
||||
<span class="flex-1">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
|
||||
@@ -465,7 +465,7 @@ async function goToResult(r) {
|
||||
}"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75 !text-[var(--primary-color)]" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
|
||||
|
||||
@@ -341,11 +341,14 @@ function onQuickCreate() {
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<!-- type="text" (não "search"): vide AppMenu.vue —
|
||||
o "search" nativo dá um X de clear próprio que
|
||||
duplica o botão custom abaixo. -->
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="rs_menu_search"
|
||||
name="rs_menu_search"
|
||||
type="search"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
|
||||
@@ -204,15 +204,28 @@ export function useLayout() {
|
||||
|
||||
const setVariant = (v, { fromUser = true } = {}) => {
|
||||
if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return;
|
||||
const prev = layoutConfig.variant;
|
||||
layoutConfig.variant = v;
|
||||
try {
|
||||
localStorage.setItem('layout_variant', v);
|
||||
} catch {}
|
||||
// reset rail state ao trocar
|
||||
layoutState.railSectionKey = null;
|
||||
layoutState.railPanelOpen = false;
|
||||
// Reset do estado do rail SÓ quando o novo variant não é 'rail'.
|
||||
// Antes, o reset acontecia em todo setVariant — incluindo na troca
|
||||
// de volta pra rail (ex.: rail → melissa → rail). Resultado: o rail
|
||||
// remontava sem seção ativa e o menu da esquerda aparecia sem itens
|
||||
// até o usuário clicar em algo. Preservar o estado quando volta pra
|
||||
// rail mantém a UX coerente.
|
||||
if (v !== 'rail') {
|
||||
layoutState.railSectionKey = null;
|
||||
layoutState.railPanelOpen = false;
|
||||
}
|
||||
// marca que o usuário fez uma escolha explícita (não restauração do DB)
|
||||
if (fromUser) layoutState._variantDirty = true;
|
||||
// dev-only: facilita auditar trocas de layout sem instrumentar o profile
|
||||
if (typeof window !== 'undefined' && window?.__DEV_LAYOUT_LOG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[layout.setVariant]', prev, '→', v);
|
||||
}
|
||||
};
|
||||
|
||||
const setRailOpenMode = (mode) => {
|
||||
|
||||
@@ -535,6 +535,9 @@ async function saveJornada() {
|
||||
cfg.value.setup_clinica_concluido = true;
|
||||
cfg.value.jornada_igual_todos = igualTodos;
|
||||
toast.add({ severity: 'success', summary: 'Jornada salva', detail: 'Horários de trabalho atualizados.', life: 3500 });
|
||||
// Notifica consumidores (ex: MelissaLayout/timeline) pra refetch
|
||||
// do agenda_regras_semanais sem precisar reload da página.
|
||||
window.dispatchEvent(new CustomEvent('agenda:settings-saved', { detail: { source: 'jornada' } }));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar jornada.', life: 3500 });
|
||||
} finally {
|
||||
@@ -1385,7 +1388,7 @@ const jornadaEndDate = computed({
|
||||
<div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start">
|
||||
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
|
||||
<!-- Header do preview -->
|
||||
<div class="sticky top-0 z-10">
|
||||
<div class="sticky top-0 z-10 bg-[var(--surface-card)]">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
|
||||
<div class="font-semibold text-sm">Preview da agenda</div>
|
||||
<div class="flex gap-1">
|
||||
|
||||
@@ -222,9 +222,9 @@ onMounted(async () => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
@@ -1154,9 +1154,9 @@ onBeforeUnmount(() => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
+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 };
|
||||
}
|
||||
@@ -44,11 +44,14 @@ export default {
|
||||
// ======================================================
|
||||
// 💬 CONVERSAS (CRM de WhatsApp)
|
||||
// ======================================================
|
||||
// Redirect pro layout Melissa (versão oficial). RLS do Postgres
|
||||
// garante que o admin vê dados da clínica e o terapeuta vê só os
|
||||
// dele — independente da rota de entrada. A `CRMConversasPage`
|
||||
// antiga continua no repo (sem rota apontando) até validarmos.
|
||||
{
|
||||
path: 'conversas',
|
||||
name: 'admin-conversas',
|
||||
component: () => import('@/features/conversations/CRMConversasPage.vue'),
|
||||
meta: { roles: ['clinic_admin', 'tenant_admin'] }
|
||||
redirect: { name: 'Melissa', params: { secao: 'conversas' } }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
|
||||
@@ -41,10 +41,13 @@ export default {
|
||||
// ======================================================
|
||||
// 💬 CONVERSAS (CRM de WhatsApp)
|
||||
// ======================================================
|
||||
// Redirect pro layout Melissa (versão oficial). A `CRMConversasPage`
|
||||
// antiga continua no repo (sem rota apontando) até validarmos a versão
|
||||
// Melissa por algumas semanas; depois pode ser removida.
|
||||
{
|
||||
path: 'conversas',
|
||||
name: 'therapist-conversas',
|
||||
component: () => import('@/features/conversations/CRMConversasPage.vue')
|
||||
redirect: { name: 'Melissa', params: { secao: 'conversas' } }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
|
||||
@@ -20,15 +20,18 @@ import { useToast } from 'primevue/usetoast';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useLayout as _useLayout } from '@/layout/composables/layout';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
const { setVariant } = _useLayout();
|
||||
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
import Select from 'primevue/select';
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
// `useLayout` precisa ser importado UMA vez. Antes havia 2 imports (e
|
||||
// duas chamadas: `_useLayout()` no topo + `useLayout()` mais abaixo) —
|
||||
// como o composable usa state singleton no escopo do módulo, essas duas
|
||||
// chamadas retornam o mesmo `layoutConfig`, mas o duplo import deixava
|
||||
// a leitura confusa e mascarava regressões. Unificado aqui.
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
@@ -75,6 +78,12 @@ const passwordSent = ref(false);
|
||||
|
||||
const userEmail = ref('');
|
||||
const userId = ref('');
|
||||
// `profiles.role` do usuário logado. Usado pra desabilitar o card "Melissa"
|
||||
// quando o role é `saas_admin` (Melissa não foi pensado pro shell SaaS — não
|
||||
// tem gestão de tenants nem dashboards globais). Os outros roles seguem o
|
||||
// fluxo normal.
|
||||
const userRole = ref(null);
|
||||
const isSaasAdmin = computed(() => userRole.value === 'saas_admin');
|
||||
|
||||
const fileInput = ref(null);
|
||||
|
||||
@@ -159,6 +168,76 @@ function markDirty() {
|
||||
dirty.value = true;
|
||||
}
|
||||
|
||||
// ── Trocar pra Melissa exige reload (o AppLayout não tem branch pra
|
||||
// `melissa` — quem renderiza o layout Melissa é uma rota separada
|
||||
// (`/melissa`). Sem reload, o user fica visualmente em clássico mesmo
|
||||
// tendo escolhido Melissa). Por isso confirma com o usuário antes,
|
||||
// persiste imediato no DB pra não depender do botão "Salvar alterações"
|
||||
// e redireciona pra /melissa.
|
||||
//
|
||||
// `melissaConfirmOpen` é um guard contra duplo-clique: o `:disabled` no
|
||||
// botão depende de `layoutConfig.variant === 'melissa'`, mas isso só vira
|
||||
// true depois do user aceitar o confirm. Entre o 1º clique e o accept,
|
||||
// um segundo clique abriria outro confirm sobreposto. O guard fecha
|
||||
// essa janela.
|
||||
const melissaConfirmOpen = ref(false);
|
||||
async function selectMelissa() {
|
||||
if (isSaasAdmin.value) return; // defesa em profundidade — botão também é :disabled
|
||||
if (layoutConfig.variant === 'melissa') return; // já é o atual
|
||||
if (melissaConfirmOpen.value) return; // confirm já está aberto
|
||||
|
||||
melissaConfirmOpen.value = true;
|
||||
confirm.require({
|
||||
header: 'Trocar para o layout Melissa',
|
||||
message: 'A página será recarregada para aplicar o novo layout. Confirma?',
|
||||
icon: 'pi pi-th-large',
|
||||
acceptLabel: 'Trocar e recarregar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
setVariant('melissa');
|
||||
// Persiste só o layout_variant — não chama saveAll porque o
|
||||
// resto do form pode estar dirty/inválido e não queremos
|
||||
// segurar a troca de layout por causa disso.
|
||||
if (userId.value) {
|
||||
const { error } = await supabase
|
||||
.from('user_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId.value,
|
||||
layout_variant: 'melissa',
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
);
|
||||
if (error) {
|
||||
// Tolerante a relation/RLS errors — localStorage já tem
|
||||
// o valor, então o redirect home → Melissa funciona pra
|
||||
// esta sessão mesmo se o DB falhar.
|
||||
const msg = String(error.message || '');
|
||||
const tolerant = /does not exist/i.test(msg) || /permission denied/i.test(msg) || /violates row-level security/i.test(msg);
|
||||
if (!tolerant) throw error;
|
||||
}
|
||||
}
|
||||
toast.add({ severity: 'info', summary: 'Aplicando Melissa', detail: 'Recarregando…', life: 1500 });
|
||||
// Hard reload — entra na home, beforeEach do router detecta
|
||||
// `localStorage.layout_variant === 'melissa'` e manda pra /melissa.
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
melissaConfirmOpen.value = false;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao aplicar Melissa', detail: e?.message || 'Tente novamente.', life: 4000 });
|
||||
}
|
||||
},
|
||||
reject: () => {
|
||||
melissaConfirmOpen.value = false;
|
||||
},
|
||||
onHide: () => {
|
||||
// Cobre fechamento via Esc / clickoutside (não dispara reject/accept).
|
||||
melissaConfirmOpen.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Gamificação / Progresso
|
||||
----------------------------- */
|
||||
@@ -384,7 +463,7 @@ async function uploadAvatarIfNeeded() {
|
||||
/* ----------------------------
|
||||
Aparência (SEM duplicar engine)
|
||||
----------------------------- */
|
||||
const { layoutConfig, layoutState, toggleDarkMode, changeMenuMode } = useLayout();
|
||||
const { layoutConfig, layoutState, setVariant, toggleDarkMode, changeMenuMode } = useLayout();
|
||||
|
||||
function isDarkNow() {
|
||||
return document.documentElement.classList.contains('app-dark');
|
||||
@@ -532,12 +611,13 @@ async function loadProfile() {
|
||||
const { data: prof, error: pErr } = await supabase
|
||||
.from('profiles')
|
||||
.select(
|
||||
'full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
|
||||
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
|
||||
)
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!pErr && prof) {
|
||||
userRole.value = prof.role || null;
|
||||
form.full_name = prof.full_name ?? form.full_name;
|
||||
form.avatar_url = prof.avatar_url ?? form.avatar_url;
|
||||
form.phone = prof.phone ?? '';
|
||||
@@ -671,6 +751,20 @@ async function saveAll() {
|
||||
clearAvatarFile();
|
||||
dirty.value = false;
|
||||
layoutState._variantDirty = false;
|
||||
|
||||
// Se trocou de Melissa pra outro layout estando dentro de /melissa,
|
||||
// o MelissaLayout (rota fullscreen, fora do AppLayout) continua
|
||||
// montado mesmo com o flag novo no localStorage/DB. Hard reload pra
|
||||
// home: o beforeEach do router lê o flag e cai no layout correto.
|
||||
// Caso inverso (qualquer → melissa) já é tratado no selectMelissa.
|
||||
const variantAgora = layoutConfig.variant;
|
||||
const naMelissa = router.currentRoute.value.path.startsWith('/melissa');
|
||||
if (naMelissa && variantAgora !== 'melissa') {
|
||||
toast.add({ severity: 'info', summary: 'Aplicando layout', detail: 'Recarregando…', life: 1500 });
|
||||
setTimeout(() => window.location.assign('/'), 700);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Seu perfil foi atualizado.', life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || 'Não consegui salvar.', life: 6000 });
|
||||
@@ -1368,9 +1462,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Layout 1: Clássico -->
|
||||
<!-- Desabilitado quando já é o variant ativo: evita re-clicar
|
||||
no layout em uso (que dispararia setVariant inutilmente
|
||||
e — no caso do rail — causava remount do menu sumindo
|
||||
os itens visualmente). -->
|
||||
<button
|
||||
class="lv-card"
|
||||
:class="{ 'lv-card--active': layoutConfig.variant === 'classic' }"
|
||||
:class="{
|
||||
'lv-card--active': layoutConfig.variant === 'classic',
|
||||
'lv-card--current': layoutConfig.variant === 'classic'
|
||||
}"
|
||||
:disabled="layoutConfig.variant === 'classic'"
|
||||
v-tooltip.top="layoutConfig.variant === 'classic' ? 'Layout atual' : null"
|
||||
@click="
|
||||
setVariant('classic');
|
||||
markDirty();
|
||||
@@ -1398,7 +1501,12 @@ onBeforeUnmount(() => {
|
||||
<!-- Layout 2: Rail -->
|
||||
<button
|
||||
class="lv-card"
|
||||
:class="{ 'lv-card--active': layoutConfig.variant === 'rail' }"
|
||||
:class="{
|
||||
'lv-card--active': layoutConfig.variant === 'rail',
|
||||
'lv-card--current': layoutConfig.variant === 'rail'
|
||||
}"
|
||||
:disabled="layoutConfig.variant === 'rail'"
|
||||
v-tooltip.top="layoutConfig.variant === 'rail' ? 'Layout atual' : null"
|
||||
@click="
|
||||
setVariant('rail');
|
||||
markDirty();
|
||||
@@ -1425,13 +1533,23 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<!-- Layout 3: Melissa (Direção B) -->
|
||||
<!-- Desabilitado em 2 cenários:
|
||||
- SaaS admin (Melissa não foi pensado pro shell SaaS)
|
||||
- já é o variant ativo (evita re-disparar o confirm
|
||||
dialog múltiplas vezes — bug onde clicar de novo
|
||||
abria 2 confirms sobrepostos) -->
|
||||
<button
|
||||
class="lv-card"
|
||||
:class="{ 'lv-card--active': layoutConfig.variant === 'melissa' }"
|
||||
@click="
|
||||
setVariant('melissa');
|
||||
markDirty();
|
||||
"
|
||||
:class="{
|
||||
'lv-card--active': layoutConfig.variant === 'melissa',
|
||||
'lv-card--current': layoutConfig.variant === 'melissa',
|
||||
'lv-card--disabled': isSaasAdmin
|
||||
}"
|
||||
:disabled="isSaasAdmin || layoutConfig.variant === 'melissa'"
|
||||
v-tooltip.top="isSaasAdmin
|
||||
? 'Não disponível para o perfil SaaS'
|
||||
: (layoutConfig.variant === 'melissa' ? 'Layout atual' : null)"
|
||||
@click="selectMelissa"
|
||||
>
|
||||
<span class="lv-card__badge lv-card__badge--beta">Beta</span>
|
||||
<div class="lv-card__preview lv-card__preview--melissa">
|
||||
@@ -1649,6 +1767,52 @@ onBeforeUnmount(() => {
|
||||
.lv-card--active .lv-card__radio {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
/* Garante que o <button disabled> não responda a hover/click — alguns
|
||||
browsers ainda processam pointer events em <button disabled> com CSS
|
||||
conflitante. Forçar `pointer-events: none` fecha qualquer brecha. */
|
||||
.lv-card:disabled,
|
||||
.lv-card[disabled] {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* Disabled state — usado pra Melissa quando role=saas_admin. Cinza forte. */
|
||||
.lv-card--disabled {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
.lv-card--disabled:hover {
|
||||
border-color: var(--surface-border);
|
||||
}
|
||||
/* Current state — usado quando o variant já é o ativo. Mantém o border
|
||||
colorido do --active (a identidade de "ativo" continua), mas escurece
|
||||
sutilmente e mostra um badge "Atual" pra deixar inequívoco que esse é
|
||||
o layout em uso e não responde a clique.
|
||||
`pointer-events: none` reforça o :disabled do <button>. */
|
||||
.lv-card--current {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lv-card--current:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
}
|
||||
.lv-card--current::before {
|
||||
content: 'Atual';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lv-card__preview {
|
||||
height: 90px;
|
||||
display: flex;
|
||||
|
||||
@@ -1109,9 +1109,9 @@ onMounted(() => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
@@ -1203,9 +1203,9 @@ onMounted(() => {
|
||||
class="dc-dialog w-[36rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
@@ -1267,9 +1267,9 @@ onMounted(() => {
|
||||
class="dc-dialog w-[32rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' }
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
|
||||
@@ -425,9 +425,9 @@ onMounted(load);
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
|
||||
}"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
@@ -27,6 +27,10 @@ import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vu
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
||||
import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue';
|
||||
// Timeline com paridade ao Melissa: respeita jornada (agenda_regras_semanais),
|
||||
// folga/feriado, scroll horizontal com min-slot + auto-scroll, eco lateral.
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
|
||||
const dashHeroSentinelRef = ref(null);
|
||||
const heroStuck = ref(false);
|
||||
@@ -644,15 +648,47 @@ const commitments = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const hoursRange = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
const TL_START = 7,
|
||||
TL_END = 20,
|
||||
TL_SPAN = TL_END - TL_START;
|
||||
function toPercent(h, m) {
|
||||
return ((h + m / 60 - TL_START) / TL_SPAN) * 100;
|
||||
// ── Timeline: range derivado de agenda_regras_semanais (regra do dia
|
||||
// atual). Fallback: agenda_configuracoes global → 07–20h. Range expande
|
||||
// pra incluir eventos fora do expediente. Espelha o Melissa.
|
||||
const { settings: agendaSettings, workRules: agendaWorkRules, load: loadAgendaSettings } = useAgendaSettings();
|
||||
const { todos: feriadosList, load: loadFeriadosBase } = useFeriados();
|
||||
onMounted(() => {
|
||||
loadAgendaSettings();
|
||||
});
|
||||
|
||||
function _timeStrToHour(s, fb) {
|
||||
const str = String(s || '').slice(0, 5);
|
||||
const [h, m] = str.split(':').map(Number);
|
||||
if (Number.isFinite(h) && Number.isFinite(m)) return h + m / 60;
|
||||
return fb;
|
||||
}
|
||||
const todayRules = computed(() => {
|
||||
const dow = new Date().getDay();
|
||||
return (agendaWorkRules.value || []).filter((r) => r.dia_semana === dow && r.ativo !== false);
|
||||
});
|
||||
const isFolga = computed(() => todayRules.value.length === 0);
|
||||
const todayFeriado = computed(() => {
|
||||
const d = new Date();
|
||||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return (feriadosList.value || []).find((f) => f.data === k) || null;
|
||||
});
|
||||
function _baseTimelineRange() {
|
||||
const rules = todayRules.value;
|
||||
if (rules.length > 0) {
|
||||
const starts = rules.map((r) => _timeStrToHour(r.hora_inicio, 7));
|
||||
const ends = rules.map((r) => _timeStrToHour(r.hora_fim, 20));
|
||||
return { start: Math.min(...starts), end: Math.max(...ends) };
|
||||
}
|
||||
const s = agendaSettings.value;
|
||||
const fbStart = (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '07:00';
|
||||
const fbEnd = (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '20:00';
|
||||
return { start: _timeStrToHour(fbStart, 7), end: _timeStrToHour(fbEnd, 20) };
|
||||
}
|
||||
|
||||
const timelineEvents = computed(() =>
|
||||
// `timelineEventsRaw` extrai a info dos eventos SEM depender de TL_START/TL_END
|
||||
// (pra evitar dep circular: TL_START depende dos eventos pra expandir o range).
|
||||
const timelineEventsRaw = computed(() =>
|
||||
_statsDoMes.value.timelineLista
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
|
||||
@@ -660,25 +696,178 @@ const timelineEvents = computed(() =>
|
||||
const item = buildEventoItem(ev);
|
||||
const [hh, mm] = item.hora.split(':').map(Number);
|
||||
const durMin = parseInt(item.dur) || 50;
|
||||
const startH = hh + mm / 60;
|
||||
const endH = startH + durMin / 60;
|
||||
return {
|
||||
id: item.id,
|
||||
inicio_em: ev.inicio_em,
|
||||
label: item.nome.split(' ')[0],
|
||||
tipo: item.tipo,
|
||||
status: item.status,
|
||||
modalidade: item.modalidade,
|
||||
tooltip: `${item.hora} · ${item.nome} · ${item.modalidade}`,
|
||||
badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '',
|
||||
bgColor: item.bgColor,
|
||||
txtColor: item.txtColor,
|
||||
style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' }
|
||||
durMin,
|
||||
startH,
|
||||
endH
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const TL_START = computed(() => {
|
||||
const { start } = _baseTimelineRange();
|
||||
const evs = timelineEventsRaw.value || [];
|
||||
const minEv = evs.length ? Math.min(...evs.map((e) => e.startH)) : Infinity;
|
||||
return Math.max(0, Math.floor(Math.min(start, minEv)));
|
||||
});
|
||||
const TL_END = computed(() => {
|
||||
const { end } = _baseTimelineRange();
|
||||
const evs = timelineEventsRaw.value || [];
|
||||
const maxEv = evs.length ? Math.max(...evs.map((e) => e.endH)) : -Infinity;
|
||||
return Math.min(24, Math.ceil(Math.max(end, maxEv)));
|
||||
});
|
||||
const hoursRange = computed(() => {
|
||||
const arr = [];
|
||||
for (let h = TL_START.value; h <= TL_END.value; h++) arr.push(h);
|
||||
return arr;
|
||||
});
|
||||
function toPercent(h, m) {
|
||||
const span = TL_END.value - TL_START.value;
|
||||
if (span <= 0) return 0;
|
||||
return ((h + m / 60 - TL_START.value) / span) * 100;
|
||||
}
|
||||
|
||||
// `timelineEvents` adiciona positioning (left/width %) baseado em TL_START/END.
|
||||
const timelineEvents = computed(() =>
|
||||
timelineEventsRaw.value.map((ev) => {
|
||||
const span = TL_END.value - TL_START.value;
|
||||
const left = span > 0 ? ((ev.startH - TL_START.value) / span) * 100 : 0;
|
||||
const width = span > 0 ? Math.max(((ev.endH - ev.startH) / span) * 100, 4) : 4;
|
||||
return { ...ev, style: { left: `${left}%`, width: `${width}%` } };
|
||||
})
|
||||
);
|
||||
|
||||
const nowCursorLeft = computed(() => {
|
||||
const pct = toPercent(agora.value.getHours(), agora.value.getMinutes());
|
||||
return Math.min(Math.max(pct, 0), 100) + '%';
|
||||
});
|
||||
|
||||
// ── Scroll horizontal + eco lateral (paridade Melissa) ──────────
|
||||
// scroll state, eco state, auto-scroll to-now, scrollToEvent.
|
||||
const tlHScrollEl = ref(null);
|
||||
const tlScrollState = ref({ scrollL: 0, viewW: 0, innerW: 0 });
|
||||
function _updateTlScrollState() {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) {
|
||||
tlScrollState.value = { scrollL: 0, viewW: 0, innerW: 0 };
|
||||
return;
|
||||
}
|
||||
const inner = el.firstElementChild;
|
||||
tlScrollState.value = {
|
||||
scrollL: el.scrollLeft,
|
||||
viewW: el.clientWidth,
|
||||
innerW: inner ? (inner.scrollWidth || inner.offsetWidth) : 0
|
||||
};
|
||||
}
|
||||
function onTimelineScroll() {
|
||||
_updateTlScrollState();
|
||||
}
|
||||
const tlEcoState = computed(() => {
|
||||
const { scrollL, viewW, innerW } = tlScrollState.value;
|
||||
const total = TL_END.value - TL_START.value;
|
||||
const empty = { left: [], right: [], vStart: TL_START.value, vEnd: TL_END.value };
|
||||
if (total <= 0 || !innerW || !viewW || innerW <= viewW) return empty;
|
||||
const vStart = TL_START.value + (scrollL / innerW) * total;
|
||||
const vEnd = TL_START.value + ((scrollL + viewW) / innerW) * total;
|
||||
const left = [];
|
||||
const right = [];
|
||||
for (const ev of timelineEventsRaw.value) {
|
||||
if (ev.endH <= vStart) left.push(ev);
|
||||
else if (ev.startH >= vEnd) right.push(ev);
|
||||
}
|
||||
return { left, right, vStart, vEnd };
|
||||
});
|
||||
function ecoTickStyle(ev, side) {
|
||||
const { vStart, vEnd } = tlEcoState.value;
|
||||
let topPct = 50;
|
||||
if (side === 'left') {
|
||||
const span = vStart - TL_START.value;
|
||||
if (span > 0) topPct = ((ev.startH - TL_START.value) / span) * 100;
|
||||
} else {
|
||||
const span = TL_END.value - vEnd;
|
||||
if (span > 0) topPct = ((ev.startH - vEnd) / span) * 100;
|
||||
}
|
||||
return {
|
||||
top: `${Math.max(0, Math.min(100, topPct))}%`,
|
||||
backgroundColor: ev.bgColor || '#6366f1'
|
||||
};
|
||||
}
|
||||
function scrollToEvent(ev) {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) return;
|
||||
const inner = el.firstElementChild;
|
||||
if (!inner) return;
|
||||
const innerWidth = inner.scrollWidth || inner.offsetWidth;
|
||||
const visibleWidth = el.clientWidth;
|
||||
const total = TL_END.value - TL_START.value;
|
||||
if (total <= 0) return;
|
||||
const ratio = (ev.startH - TL_START.value) / total;
|
||||
const target = Math.max(0, Math.min(innerWidth - visibleWidth, ratio * innerWidth - visibleWidth / 2));
|
||||
el.scrollTo({ left: target, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
let _tlAutoScrolled = false;
|
||||
function _scrollTimelineToNow() {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) return;
|
||||
const h = agora.value.getHours() + agora.value.getMinutes() / 60;
|
||||
const total = TL_END.value - TL_START.value;
|
||||
if (total <= 0 || h < TL_START.value || h > TL_END.value) return;
|
||||
const inner = el.firstElementChild;
|
||||
if (!inner) return;
|
||||
const innerWidth = inner.scrollWidth || inner.offsetWidth;
|
||||
const visibleWidth = el.clientWidth;
|
||||
if (innerWidth <= visibleWidth) return;
|
||||
const ratio = (h - TL_START.value) / total;
|
||||
el.scrollLeft = Math.max(0, ratio * innerWidth - visibleWidth / 2);
|
||||
}
|
||||
onMounted(() => {
|
||||
const stop = watch(
|
||||
[TL_START, TL_END],
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (!_tlAutoScrolled) {
|
||||
_scrollTimelineToNow();
|
||||
const el = tlHScrollEl.value;
|
||||
const inner = el?.firstElementChild;
|
||||
if (inner && inner.scrollWidth > el.clientWidth) {
|
||||
_tlAutoScrolled = true;
|
||||
stop();
|
||||
}
|
||||
}
|
||||
_updateTlScrollState();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const el = tlHScrollEl.value;
|
||||
if (el && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => _updateTlScrollState());
|
||||
ro.observe(el);
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
||||
onBeforeUnmount(() => ro.disconnect());
|
||||
}
|
||||
});
|
||||
|
||||
function _fmtHora(h) {
|
||||
const horas = Math.floor(h);
|
||||
const mins = Math.round((h - horas) * 60);
|
||||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
@@ -689,6 +878,10 @@ async function load() {
|
||||
}
|
||||
await tenantStore.ensureLoaded();
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
// Feriados pra badge da timeline (precisa do tenant — RLS).
|
||||
if (tid) {
|
||||
loadFeriadosBase(tid).catch(() => {});
|
||||
}
|
||||
await loadCommitments();
|
||||
const mesInicio = new Date(anoAtual, mesAtual, 1, 0, 0, 0, 0).toISOString();
|
||||
const mesFim = new Date(anoAtual, mesAtual + 1, 0, 23, 59, 59, 999).toISOString();
|
||||
@@ -987,7 +1180,25 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
<span
|
||||
v-if="todayFeriado"
|
||||
class="dash-tl-badge dash-tl-badge--feriado"
|
||||
:title="`Feriado: ${todayFeriado.nome}`"
|
||||
>
|
||||
<i class="pi pi-star text-[0.6rem]" />
|
||||
Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isFolga"
|
||||
class="dash-tl-badge dash-tl-badge--folga"
|
||||
title="Hoje não é dia de trabalho na sua agenda — sessões fora do expediente continuam permitidas."
|
||||
>
|
||||
<i class="pi pi-moon text-[0.6rem]" />
|
||||
Folga
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
||||
<span class="pulse-dot w-[15px] h-[5px] rounded-full bg-red-500"></span>
|
||||
Agora: {{ horaAtual }}
|
||||
@@ -995,37 +1206,74 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exemplo: badge ou ação -->
|
||||
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="rounded-full" title="Ver sua Agenda" @click="$router.push('/therapist/agenda')" label="Agenda" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2.5 relative">
|
||||
<div class="flex justify-between mb-1">
|
||||
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
|
||||
<span class="text-[0.80rem] text-[var(--text-color-secondary)] font-semibold">{{ h }}h</span>
|
||||
<!-- Frame relativo abriga scroll horizontal + eco lateral overlay -->
|
||||
<div class="dash-tl-frame mt-2.5 relative">
|
||||
<div ref="tlHScrollEl" class="dash-tl-scroll" @scroll.passive="onTimelineScroll">
|
||||
<div class="dash-tl-inner relative" :style="{ '--m-tl-cols': TL_END - TL_START }">
|
||||
<div class="flex justify-between mb-1">
|
||||
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
|
||||
<span class="text-[0.80rem] text-[var(--text-color-secondary)] font-semibold">{{ h }}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-10 bg-[var(--surface-ground,#f8fafc)] rounded-md overflow-visible">
|
||||
<div
|
||||
v-for="ev in timelineEvents"
|
||||
:key="ev.id"
|
||||
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
|
||||
:style="{ ...ev.style, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
|
||||
:class="{
|
||||
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
|
||||
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
|
||||
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado'
|
||||
}"
|
||||
:title="ev.tooltip"
|
||||
>
|
||||
<span class="text-[0.58rem] font-bold text-white truncate">{{ ev.label }}</span>
|
||||
<span v-if="ev.badge" class="text-xs ml-auto">{{ ev.badge }}</span>
|
||||
</div>
|
||||
<div class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20" :style="{ left: nowCursorLeft }">
|
||||
<div class="w-0.5 h-full bg-red-500 opacity-80" />
|
||||
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-10 bg-[var(--surface-ground,#f8fafc)] rounded-md overflow-visible">
|
||||
<div
|
||||
v-for="ev in timelineEvents"
|
||||
:key="ev.id"
|
||||
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
|
||||
:style="{ ...ev.style, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
|
||||
:class="{
|
||||
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
|
||||
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
|
||||
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado'
|
||||
}"
|
||||
:title="ev.tooltip"
|
||||
>
|
||||
<span class="text-[0.58rem] font-bold text-white truncate">{{ ev.label }}</span>
|
||||
<span v-if="ev.badge" class="text-xs ml-auto">{{ ev.badge }}</span>
|
||||
</div>
|
||||
<div class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20" :style="{ left: nowCursorLeft }">
|
||||
<div class="w-0.5 h-full bg-red-500 opacity-80" />
|
||||
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
|
||||
</div>
|
||||
|
||||
<!-- Eco lateral — minimap pulsante de cores. Tracinhos
|
||||
posicionados por tempo, click suaviza scroll até o evento. -->
|
||||
<div
|
||||
v-if="tlEcoState.left.length"
|
||||
class="dash-tl-eco dash-tl-eco--left"
|
||||
:title="`${tlEcoState.left.length} antes — clique pra centralizar`"
|
||||
>
|
||||
<button
|
||||
v-for="ev in tlEcoState.left"
|
||||
:key="`eco-l-${ev.id}`"
|
||||
type="button"
|
||||
class="dash-tl-eco__tick"
|
||||
:style="ecoTickStyle(ev, 'left')"
|
||||
:title="`${_fmtHora(ev.startH)} · ${ev.label}`"
|
||||
@click="scrollToEvent(ev)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="tlEcoState.right.length"
|
||||
class="dash-tl-eco dash-tl-eco--right"
|
||||
:title="`${tlEcoState.right.length} à frente — clique pra centralizar`"
|
||||
>
|
||||
<button
|
||||
v-for="ev in tlEcoState.right"
|
||||
:key="`eco-r-${ev.id}`"
|
||||
type="button"
|
||||
class="dash-tl-eco__tick"
|
||||
:style="ecoTickStyle(ev, 'right')"
|
||||
:title="`${_fmtHora(ev.startH)} · ${ev.label}`"
|
||||
@click="scrollToEvent(ev)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1423,4 +1671,103 @@ onMounted(async () => {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Timeline horizontal — paridade com o Melissa ──────────────
|
||||
Range derivado de agenda_regras_semanais + scroll horizontal
|
||||
com min-width de slot pra legibilidade + eco lateral pra eventos
|
||||
off-screen. CSS escopado ao Dashboard (mesmas classes existem
|
||||
no MelissaLayout sob outros nomes — duplicação consciente até
|
||||
eventual extração pra componente compartilhado). */
|
||||
.dash-tl-frame { position: relative; }
|
||||
|
||||
.dash-tl-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-300, #cbd5e1) transparent;
|
||||
}
|
||||
.dash-tl-scroll::-webkit-scrollbar { height: 6px; }
|
||||
.dash-tl-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.dash-tl-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-300, #cbd5e1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.dash-tl-inner {
|
||||
/* --m-tl-cols inline = TL_END - TL_START. Default 13 cobre 7→20. */
|
||||
min-width: calc(var(--m-tl-cols, 13) * 80px);
|
||||
}
|
||||
|
||||
/* Badges (Folga / Feriado) — light theme nativo */
|
||||
.dash-tl-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dash-tl-badge--folga {
|
||||
background: var(--surface-100, #f1f5f9);
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
border: 1px solid var(--surface-300, #cbd5e1);
|
||||
}
|
||||
.dash-tl-badge--feriado {
|
||||
background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent);
|
||||
color: rgb(180, 83, 9);
|
||||
border: 1px solid color-mix(in srgb, rgb(217, 119, 6) 32%, transparent);
|
||||
}
|
||||
|
||||
/* Eco lateral — minimap pulsante */
|
||||
.dash-tl-eco {
|
||||
position: absolute;
|
||||
top: 24px; /* alinha com a barra (descontando linha de horas) */
|
||||
bottom: 8px;
|
||||
width: 8px;
|
||||
z-index: 6;
|
||||
pointer-events: auto;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface-200, #e2e8f0) 70%, transparent);
|
||||
border: 1px solid var(--surface-300, #cbd5e1);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
animation: dash-tl-eco-pulse 2400ms ease-in-out infinite;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.dash-tl-eco--left { left: -2px; }
|
||||
.dash-tl-eco--right { right: -2px; }
|
||||
|
||||
.dash-tl-eco__tick {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
height: 4px;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
transition: opacity 140ms ease, transform 140ms ease, height 140ms ease;
|
||||
}
|
||||
.dash-tl-eco__tick:hover {
|
||||
opacity: 1;
|
||||
height: 6px;
|
||||
transform: translateY(-50%) scaleX(2.2);
|
||||
z-index: 1;
|
||||
}
|
||||
.dash-tl-eco--left .dash-tl-eco__tick:hover { transform-origin: left center; }
|
||||
.dash-tl-eco--right .dash-tl-eco__tick:hover { transform-origin: right center; }
|
||||
|
||||
@keyframes dash-tl-eco-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary-color, #6366f1) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px 1px color-mix(in srgb, var(--primary-color, #6366f1) 28%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user