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:
@@ -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