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
+175 -11
View File
@@ -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 é 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)
- é 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;
+6 -6
View File
@@ -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' } }
}"
+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>