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:
Leonardo
2026-05-04 11:41:19 -03:00
parent 269c380d9c
commit 86311ef305
52 changed files with 16214 additions and 1027 deletions
+382 -35
View File
@@ -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 → 0720h. 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>