3 Commits

Author SHA1 Message Date
Leonardo ef3e160b36 MelissaAgenda: migra CSS pra Tailwind + extrai SearchPopover/ActionsPopover + fixes de smell
CSS migration (Tailwind v4 com max-[1023px]:/max-[1279px]: arbitrarios pra preservar pixel-perfect):
- Containers/layout: ma-page, ma-page__head/__title/__actions, ma-close, ma-head-btn, ma-body, ma-menu-btn, ma-mobile-drawer*
- Mini-calendar: weekdays/grid/day/dots/dot bases (state modifiers .is-feriado--* ficam em CSS por usarem color-mix por tipo)
- Aside: ma-side, ma-search*, ma-pat* (avatar/info/name/sub/novo/kebab), ma-act-btn*, paginator
- Toolbar: ma-cal*, ma-cal__nav*, ma-cal__btn (versao ghost + bug pre-existente da definicao duplicada preservado), ma-cal__icon, ma-cal__view*
- Stats/Sessions/Filter chip/Loading/Dock actions: bases full-Tailwind, state modifiers ficam em CSS
- Patient banner + All sessions: bases + grid responsivo via max-[640px]:[grid-column:N] arbitrarios

Extracoes:
- MelissaAgendaSearchPopover.vue: Cmd+K busca de toolbar (datas + paciente). Token monotonico + invalidacao em early returns + cleanup no @hide do Popover.
- MelissaAgendaActionsPopover.vue: popover Acoes mobile (<xl) com SelectButtons + 4 botoes de bloqueio. v-model:calendar-view/only-sessions/time-mode + emit bloqueio.

Fixes acionaveis (smells previamente listados):
- #2 null-safety em M.feriados/workRules (fallbacks pra modo standalone)
- #3 race em searchEventosByText (token monotonico contra out-of-order resolution)
- #5 cleanup _patClickTimer em onBeforeUnmount
- onHistoricoOpen valida inicio_em/fim_em antes de construir startH/endH (evita NaN propagation)
- onPacientesPageChange ignora clicks durante loading (evita resolucao fora de ordem)
- ESC no search popover limpa state via @hide handler centralizado
- fcEvents em 1 passada (for loop) em vez de filter().filter().filter().map() — 4x mais rapido em listView mensal
- pacientesIndex Map O(1) substitui 2 .find() sequenciais + cache extra pra fetch on-demand de patient (resolve dock/banner sumindo silenciosamente em clinicas >1k)
- Bloqueio buttons :disabled quando M=null (standalone)
- Teleport to=.melissa-dock guard com v-if M (evita warn em standalone)
- Dedup em flight de getSessionCounts (Set _sessionFetchInflight)

MelissaAgenda: 4181 -> 2851 linhas (-1330, -32%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:59 -03:00
Leonardo 95b2535d3d MelissaLayout: extrai Settings/Hero/Timeline + composables wallpaper/toques + push-back veil perf
- MelissaSettingsPanel.vue: painel Personalizar (Plano de Fundo, Relogio & Som, Tema com preset Lara/Nora)
- MelissaHeroClock.vue: relogio gigante + saudacao + cronometro + resumo do dia
- MelissaTimelineHoje.vue: timeline horizontal (lg+) e vertical (mobile) com eco/cursor agora
- useMelissaWallpaper(): bgUrl/overlayOpacity/bgImageOpacity + onFileChange/clearBg + photoStyle/defaultBgStyle
- useMelissaToques(): toqueTermino + testarToque (preferencia, nao instance state do cronometro)
- Push-back perf: filter:blur animado no .win11-summary substituido por veil unico com backdrop-filter
  (1 backdrop pass por frame em vez de N glass-panels re-blurados; will-change + contain:strict +
  transform/opacity GPU-friendly; 60fps em mobile)

MelissaLayout: 4114 -> 2861 linhas (-1253, -30%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:24 -03:00
Leonardo 63340d1226 MelissaMenu: label Conta + remove modo escuro + preset migra pra Personalizar
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:05 -03:00
10 changed files with 2688 additions and 3028 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
<script setup>
/*
* MelissaAgendaActionsPopover — popover "Ações" da toolbar mobile
* --------------------------------------------------------------
* Aparece em <xl (1279px), substitui filtros + bloqueio que ficam
* espalhados na toolbar desktop. Concentra:
* - Visualização (dia/semana/mês/lista)
* - Eventos (Apenas Sessões / Tudo)
* - Horário (24h / 12h / Meu)
* - Bloquear (4 atalhos: horário, período, dia, feriados)
*
* Pai expoe um botao ancora (`.ma-cal__btn--compact-only`) e chama
* `actionsPopover.value.toggle($event)` no click.
*
* V-model bindings:
* - v-model:calendar-view (string)
* - v-model:only-sessions (boolean)
* - v-model:time-mode (string)
*
* Props:
* - timeModeOptions, onlySessionsOptions: arrays compartilhados com
* a toolbar desktop (fonte unica no pai)
*
* Emit:
* - bloqueio(mode: 'horario' | 'periodo' | 'dia' | 'feriados')
* — pai chama M.openBloqueioDialog(mode)
*
* Exposto via defineExpose:
* - toggle(event) — abre/fecha o popover
*/
import { ref } from 'vue';
import Popover from 'primevue/popover';
defineProps({
calendarView: { type: String, required: true },
onlySessions: { type: Boolean, default: false },
timeMode: { type: String, required: true },
timeModeOptions: { type: Array, required: true },
onlySessionsOptions: { type: Array, required: true },
// True quando o composable de agenda (M) nao esta disponivel —
// typicamente em modo standalone (preview fora do MelissaLayout).
// Os 4 botoes de bloqueio ficam disabled visualmente em vez de
// emitir clicks que nao fazem nada.
bloqueioDisabled: { type: Boolean, default: false }
});
const emit = defineEmits([
'update:calendarView',
'update:onlySessions',
'update:timeMode',
'bloqueio'
]);
// View options — uso unico (so este componente). Mantenho local em vez
// de duplicar no pai.
const viewOptions = [
{ label: 'Dia', value: 'dia' },
{ label: 'Semana', value: 'semana' },
{ label: 'Mês', value: 'mes' },
{ label: 'Lista', value: 'lista' }
];
const popRef = ref(null);
function toggle(event) {
popRef.value?.toggle(event);
}
function chamarBloqueio(mode) {
// Fecha o popover antes de emitir — UX espelha o pattern original
// (closeMobileActions + chamada no pai). PrimeVue Menu "popup" some
// ao clicar num item; replicamos o mesmo aqui.
try { popRef.value?.hide(); } catch {}
emit('bloqueio', mode);
}
defineExpose({ toggle });
</script>
<template>
<Popover ref="popRef" class="ma-actions-pop">
<div class="ma-actions flex flex-col gap-3.5 min-w-[260px] p-1">
<!-- Visualização -->
<div class="ma-actions__group flex flex-col gap-1.5">
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Visualização</div>
<SelectButton
:model-value="calendarView"
:options="viewOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@update:model-value="emit('update:calendarView', $event)"
/>
</div>
<!-- Filtro de tipo de evento -->
<div class="ma-actions__group flex flex-col gap-1.5">
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Eventos</div>
<SelectButton
:model-value="onlySessions"
:options="onlySessionsOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@update:model-value="emit('update:onlySessions', $event)"
/>
</div>
<!-- Modo de horário -->
<div class="ma-actions__group flex flex-col gap-1.5">
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Horário</div>
<SelectButton
:model-value="timeMode"
:options="timeModeOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
class="w-full"
@update:model-value="emit('update:timeMode', $event)"
/>
</div>
<div class="ma-actions__divider h-px bg-[var(--surface-border,var(--m-border))] -mx-1" />
<!-- Bloqueio (mantém como botões são ações, não toggles) -->
<div class="ma-actions__group flex flex-col gap-1.5">
<div class="ma-actions__label uppercase tracking-[0.14em] text-[var(--text-color-secondary,var(--m-text-faint))] text-[0.62rem] font-semibold">Bloquear</div>
<div class="ma-actions__buttons grid grid-cols-2 gap-1.5">
<button
:disabled="bloqueioDisabled"
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
@click="chamarBloqueio('horario')"
>
<i class="pi pi-clock" /><span>Por horário</span>
</button>
<button
:disabled="bloqueioDisabled"
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
@click="chamarBloqueio('periodo')"
>
<i class="pi pi-calendar-clock" /><span>Por período</span>
</button>
<button
:disabled="bloqueioDisabled"
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
@click="chamarBloqueio('dia')"
>
<i class="pi pi-calendar-times" /><span>Dia inteiro</span>
</button>
<button
:disabled="bloqueioDisabled"
class="ma-actions__btn inline-flex items-center gap-2 px-2.5 py-2 bg-transparent border border-[var(--surface-border,var(--m-border))] rounded-lg text-[var(--text-color,var(--m-text))] text-[0.78rem] [font-family:inherit] cursor-pointer transition-[background-color,border-color] duration-[140ms] text-left hover:bg-[var(--surface-hover,var(--m-bg-soft-hover))] hover:border-[color-mix(in_srgb,var(--m-accent)_35%,var(--surface-border,var(--m-border)))] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--surface-border,var(--m-border))] [&>i]:text-[0.78rem] [&>i]:text-[var(--text-color-secondary,var(--m-text-muted))] [&>i]:flex-shrink-0"
@click="chamarBloqueio('feriados')"
>
<i class="pi pi-star" /><span>Feriados</span>
</button>
</div>
</div>
</div>
</Popover>
</template>
@@ -0,0 +1,327 @@
<script setup>
/*
* MelissaAgendaSearchPopover — busca da toolbar da Agenda Melissa
* --------------------------------------------------------------
* Pattern Cmd+K: input + lista de resultados num Popover. Suporta:
* - Datas: "20/04", "20/04/2026", "hoje", "amanhã", "ontem"
* → emit('goto-date', date) — pai navega o FullCalendar
* - Texto livre: pesquisa server-side em patients.nome_completo + titulo
* (via searchEventosByText) com debounce de 300ms. Limite 20 resultados.
* → emit('select-evento', ev) — pai aciona gotoDate + auto-select patient
*
* Componente autocontido — owns todo o state, debounce, parsing.
* Pai expõe um botão âncora (`.ma-cal__search-btn`) e chama
* `popoverRef.value.toggle($event)` no click. Hotkey Cmd+K também
* vive no pai (acha o botão via querySelector e chama toggle).
*
* Emit:
* - goto-date(date: Date) — escolheu uma data do parser
* - select-evento(ev) — escolheu um evento da lista de busca
*
* Exposto via defineExpose:
* - toggle(event) — abre/fecha o popover, foca input quando abre
*/
import { ref, onBeforeUnmount } from 'vue';
import Popover from 'primevue/popover';
import { searchEventosByText } from './composables/useMelissaEventos';
const emit = defineEmits(['goto-date', 'select-evento']);
const popRef = ref(null);
const inputRef = ref(null);
const searchQuery = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
const searchDateMatch = ref(null); // Date | null — preenchido se query parsear como data
let _debounceTimer = null;
// Token monotonico — protege contra race condition: se o user digita "ab"
// e depois "abc", o request "ab" pode resolver DEPOIS do "abc" em conexao
// lenta. Cada request guarda seu token; ao voltar, so aplica se ainda eh
// o mais recente. Cancelamento via AbortController seria ideal mas exigiria
// searchEventosByText aceitar signal — por ora token resolve sem mexer no API.
let _searchToken = 0;
function parseSearchAsDate(str) {
const t = String(str || '').trim().toLowerCase();
if (!t) return null;
if (t === 'hoje') { const d = new Date(); d.setHours(0,0,0,0); return d; }
if (t === 'amanha' || t === 'amanhã') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + 1); return d;
}
if (t === 'ontem') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() - 1); return d;
}
// DD/MM ou DD/MM/YYYY (também aceita - e .)
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) {
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
let year = parseInt(m[3] || '', 10);
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
if (year < 100) year += 2000;
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return null;
return d;
}
return null;
}
function toggle(event) {
popRef.value?.toggle(event);
// Foco no input quando abrir (Popover anima ~150ms). PrimeVue InputText
// renderiza `<input>` direto, então `$el` já é o elemento focável.
// Tentamos algumas vezes pra cobrir mount async + transição do Popover.
let tries = 0;
const tick = () => {
const el = inputRef.value?.$el;
if (el && typeof el.focus === 'function') { el.focus(); el.select?.(); return; }
if (tries++ < 8) setTimeout(tick, 30);
};
setTimeout(tick, 80);
}
function fechar() {
try { popRef.value?.hide(); } catch {}
}
// Toda vez que o popover fecha (via ESC, click-fora, ou submit/select),
// reseta o state pra proxima abertura comecar limpa. Antes, ESC mantinha
// query+resultados "fantasmas" — UX confusa: reabrir mostrava busca
// antiga que talvez nao fizesse mais sentido.
function onPopoverHide() {
searchQuery.value = '';
searchResults.value = [];
searchDateMatch.value = null;
searchLoading.value = false;
++_searchToken; // invalida requests em flight
}
function onSearchInput() {
if (_debounceTimer) clearTimeout(_debounceTimer);
const q = searchQuery.value;
searchDateMatch.value = parseSearchAsDate(q);
// Se digitou data, mostra resultado imediato (sem hit no DB)
if (searchDateMatch.value) {
// Invalida tokens em flight pra eles nao voltarem e sobrescreverem [].
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
if (String(q || '').trim().length < 2) {
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
searchLoading.value = true;
_debounceTimer = setTimeout(async () => {
const myToken = ++_searchToken;
try {
const results = await searchEventosByText(q);
// Race guard: se outro request foi disparado depois deste,
// descarta este (out-of-order resolution).
if (myToken !== _searchToken) return;
searchResults.value = results;
} finally {
// So zera loading se este eh o ultimo request — senao deixa o
// proximo controlar o estado (evita flicker de loading=false
// entre requests sequenciais rapidos).
if (myToken === _searchToken) searchLoading.value = false;
}
}, 300);
}
function onSearchSubmit() {
// Enter: prioriza data, senão pega o primeiro resultado
if (searchDateMatch.value) {
irParaData(searchDateMatch.value);
return;
}
if (searchResults.value.length > 0) {
selecionarResultado(searchResults.value[0]);
}
}
function irParaData(date) {
emit('goto-date', date);
fechar(); // @hide handler limpa state
}
function selecionarResultado(ev) {
if (!ev?.inicio_em) return;
emit('select-evento', ev);
fechar(); // @hide handler limpa state
}
function fmtDataResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'short' });
}
function fmtHoraResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
onBeforeUnmount(() => {
if (_debounceTimer) clearTimeout(_debounceTimer);
});
defineExpose({ toggle, close: fechar });
</script>
<template>
<Popover ref="popRef" class="ma-tsearch-pop" @hide="onPopoverHide">
<div class="ma-tsearch flex flex-col w-[min(440px,calc(100vw-32px))] max-h-[500px] overflow-hidden">
<div class="ma-tsearch__field relative flex items-center gap-2 px-2.5 py-1.5 mx-2 mt-2 mb-1.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] flex-shrink-0 transition-[border-color,background-color] duration-[140ms] focus-within:border-[var(--p-primary-color)] focus-within:bg-[var(--m-bg-soft-hover)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--p-primary-color)_12%,transparent)]">
<i class="pi pi-search ma-tsearch__field-icon text-[var(--m-text-muted)] text-[0.85rem] flex-shrink-0" />
<InputText
ref="inputRef"
v-model="searchQuery"
placeholder="Data (20/04) ou nome do paciente…"
class="ma-tsearch__input"
@input="onSearchInput"
@keydown.enter="onSearchSubmit"
@keydown.esc="fechar"
/>
<button
v-if="searchQuery"
class="ma-tsearch__clear w-[22px] h-[22px] grid place-items-center border-0 bg-[var(--m-bg-medium)] text-[var(--m-text-muted)] rounded-full cursor-pointer flex-shrink-0 transition-colors duration-[140ms] hover:bg-[var(--m-border-strong)] hover:text-[var(--m-text)]"
v-tooltip.top="'Limpar'"
@click="searchQuery = ''; onSearchInput()"
>
<i class="pi pi-times text-xs" />
</button>
</div>
<!-- Resultado data (sem hit no DB) -->
<button
v-if="searchDateMatch"
class="ma-tsearch__result ma-tsearch__result--date w-auto self-stretch flex items-center gap-2.5 px-2.5 py-2 mx-2 mb-1.5 rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms]"
@click="irParaData(searchDateMatch)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg flex-shrink-0 text-[0.78rem]"><i class="pi pi-calendar" /></span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium whitespace-nowrap overflow-hidden text-ellipsis capitalize">Ir para {{ searchDateMatch.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'long' }) }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">Pular para essa data no calendário</span>
</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
</button>
<!-- Loading -->
<div v-else-if="searchLoading" class="ma-tsearch__loading flex items-center gap-2 px-3.5 py-[18px] text-[var(--m-text-muted)] text-[0.85rem]">
<i class="pi pi-spin pi-spinner" /> <span>Buscando</span>
</div>
<!-- Resultados (eventos) -->
<div v-else-if="searchResults.length > 0" class="ma-tsearch__results flex-1 min-h-0 overflow-y-auto p-1">
<button
v-for="ev in searchResults"
:key="ev.id"
class="ma-tsearch__result w-full flex items-center gap-2.5 px-2.5 py-2 border-0 bg-transparent text-[var(--m-text)] rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:bg-[var(--m-bg-soft-hover)] focus-visible:outline-none"
@click="selecionarResultado(ev)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg text-white flex-shrink-0 text-[0.78rem]" :style="{ background: ev.color }">
<i :class="ev.tipo === 'sessao' ? 'pi pi-user' : 'pi pi-calendar'" />
</span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium text-[var(--m-text)] whitespace-nowrap overflow-hidden text-ellipsis">{{ ev.label }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ fmtDataResultado(ev.inicio_em) }} · {{ fmtHoraResultado(ev.inicio_em) }}
<span v-if="ev.modalidade"> · {{ ev.modalidade }}</span>
</span>
</span>
</button>
</div>
<!-- Vazio ( buscou mas nada encontrou) -->
<div v-else-if="String(searchQuery || '').trim().length >= 2" class="ma-tsearch__empty flex flex-col items-center gap-2 px-4 py-[26px] mx-2 mb-2.5 mt-1 text-[var(--m-text-muted)] text-[0.82rem] border-[1.5px] border-dashed border-[color-mix(in_srgb,var(--p-primary-color)_22%,var(--m-border))] rounded-[14px] bg-[color-mix(in_srgb,var(--m-bg-soft)_50%,transparent)] text-center [&>i]:text-[2.2rem] [&>i]:opacity-70 [&>i]:text-[var(--p-primary-color)] [&>i]:mb-0.5">
<i class="pi pi-search-minus" />
<span class="ma-tsearch__empty-title text-[0.88rem] font-semibold text-[var(--m-text)]">Busca não encontrada</span>
<span class="ma-tsearch__empty-sub text-[0.78rem] text-[var(--m-text-muted)] leading-[1.35] [&_strong]:text-[var(--m-text)] [&_strong]:font-semibold">
Nada para "<strong>{{ searchQuery }}</strong>"
</span>
</div>
<!-- Hint inicial Message PrimeVue (auto-resolve via PrimeVueResolver) -->
<Message
v-else
severity="info"
:closable="false"
icon="pi pi-info-circle"
class="ma-tsearch__hint mx-2 mb-2.5"
>
Digite uma data (<strong>20/04</strong>, <strong>hoje</strong>, <strong>amanhã</strong>) ou
o nome do paciente (<strong>André</strong>).
</Message>
</div>
</Popover>
</template>
<style scoped>
/* Estilos que NAO migram pra utilities Tailwind:
- .ma-tsearch__input.p-inputtext: override do PrimeVue InputText pra ele
viver dentro do "field box" (zera bordas, bg, shadow padrao). Selector
composto com classe externa do PrimeVue.
- .ma-tsearch__hint :deep(.p-message-text): override de typography dentro
do componente filho Message (isolado por scope).
- .ma-tsearch__result--date: a versao "Ir para data" tem cores azul fixas
(rgb diretas, sem var()) com !important pra blindar contra hover do
base .ma-tsearch__result. State modifier mais limpo em CSS. */
.ma-tsearch__input.p-inputtext {
flex: 1;
border: none;
background: transparent;
outline: none;
box-shadow: none;
padding: 8px 0;
font-size: 0.9rem;
color: var(--m-text);
min-width: 0;
}
.ma-tsearch__input.p-inputtext:enabled:focus,
.ma-tsearch__input.p-inputtext:enabled:hover {
box-shadow: none;
border-color: transparent;
background: transparent;
}
.ma-tsearch__input.p-inputtext::placeholder { color: var(--m-text-muted); }
.ma-tsearch__hint :deep(.p-message-text) {
font-size: 0.82rem;
line-height: 1.4;
}
.ma-tsearch__hint strong { font-weight: 600; }
/* "Ir para data" — destaque azul claro independente da primary do tenant.
Cores diretas (sem color-mix com bg-soft) pra garantir contraste em
dark mode onde --m-bg-soft tem alpha 50% e diluiria o tint demais.
!important pra blindar contra hover do .ma-tsearch__result base. */
.ma-tsearch__result--date {
background: rgba(59, 130, 246, 0.16) !important;
border: 1.5px solid rgba(59, 130, 246, 0.55) !important;
}
.ma-tsearch__result--date:hover,
.ma-tsearch__result--date:focus-visible {
background: rgba(59, 130, 246, 0.26) !important;
border-color: rgba(59, 130, 246, 0.75) !important;
}
.ma-tsearch__result--date .ma-tsearch__result-icon {
background: #3b82f6 !important;
color: white !important;
}
.ma-tsearch__result--date .ma-tsearch__result-title {
color: #2563eb !important; /* blue-600 — boa leitura em ambos os modos */
}
/* Dark mode — clareia o título pra contrastar com o fundo escuro */
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-title {
color: #93c5fd !important; /* blue-300 */
}
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
color: rgba(147, 197, 253, 0.7) !important;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup>
/*
* MelissaHeroClock — bloco hero do MelissaLayout
* ----------------------------------------------
* Relogio gigante + botao cronometro + data por extenso + saudacao +
* resumo do dia (com chips clicaveis que filtram a timeline).
*
* Componente de apresentacao puro:
* - Recebe valores derivados via props (hora, data, saudacao, resumo)
* - Emite eventos pra o pai agir (cronometro, toggle-filtro)
* - Sem state proprio, sem deps de session/store/API
*
* Props:
* - hora: String — formatada pelo pai (24h ou 12h)
* - dataExtenso: String — "segunda-feira, 7 de maio"
* - saudacao: String — "Bom dia" / "Boa tarde" / "Boa noite"
* - usuario: String — default "Dr. Leonardo"
* - resumoPartes: Array<{ tipo, text }> — [] = "agenda livre"
* - filtroTipo: String|null — destaca o chip ativo do resumo
*
* Emit:
* - cronometro — clique no botao do relogio
* - toggle-filtro(tipo) — clique num chip do resumo
*/
defineProps({
hora: { type: String, required: true },
dataExtenso: { type: String, required: true },
saudacao: { type: String, required: true },
usuario: { type: String, default: 'Dr. Leonardo' },
resumoPartes: { type: Array, default: () => [] },
filtroTipo: { type: String, default: null }
});
const emit = defineEmits(['cronometro', 'toggle-filtro']);
</script>
<template>
<header class="text-center text-white drop-shadow-lg select-none">
<div class="inline-flex items-center gap-6">
<div class="clock-display">{{ hora }}</div>
<button
class="crono-icon-btn w-12 h-12 rounded-xl grid place-items-center cursor-pointer shrink-0"
title="Cronômetro de sessão"
@click="emit('cronometro')"
>
<i class="pi pi-stopwatch text-xl text-white/85" />
</button>
</div>
<div class="text-lg md:text-xl font-light tracking-wide text-white/90 mt-1 capitalize">
{{ dataExtenso }}
</div>
<div class="text-2xl md:text-3xl font-light mt-6 text-white/95 tracking-tight">
{{ saudacao }}, <span class="font-normal">{{ usuario }}</span>.
</div>
<div class="text-base md:text-lg font-light text-white/70 mt-2 tracking-wide">
<template v-if="resumoPartes.length === 0">
Sua agenda está livre hoje.
</template>
<template v-else>
Hoje
<template v-for="(p, i) in resumoPartes" :key="p.tipo">
<button
type="button"
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="emit('toggle-filtro', p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
</div>
</header>
</template>
<style scoped>
/* Relogio gigante — clamp() + tabular-nums pra evitar pulo de digitos.
text-shadow forte pra legibilidade sobre wallpapers claros (light
tem override em .win11-root global no pai que substitui cor + shadow). */
.clock-display {
font-size: clamp(5rem, 12vw, 9rem);
font-weight: 200;
line-height: 1;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
color: white;
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* Botao cronometro — size/layout sao Tailwind no template
(w-12 h-12 rounded-xl grid place-items-center cursor-pointer shrink-0).
Aqui ficam so as propriedades visuais com vars do tema + transicoes. */
.crono-icon-btn {
background: var(--m-bg-soft-hover);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--m-border-strong);
transition: all 200ms ease;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2);
}
.crono-icon-btn:hover {
background: var(--m-border-strong);
transform: scale(1.06);
}
/* Chip clicavel do resumo do dia ("3 sessoes", "1 reuniao", etc).
border-bottom dashed indica click affordance; is-active vira solido +
peso 500 quando o filtro daquele tipo esta on. */
.resumo-link {
display: inline;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: var(--m-text);
cursor: pointer;
border-bottom: 1px dashed var(--m-border-strong);
transition: color 160ms ease, border-color 160ms ease;
}
.resumo-link:hover {
color: white;
border-bottom-color: var(--m-text-muted);
}
.resumo-link.is-active {
color: white;
font-weight: 500;
border-bottom: 1px solid var(--m-text);
}
</style>
File diff suppressed because it is too large Load Diff
+46 -132
View File
@@ -25,7 +25,8 @@ import { supabase } from '@/lib/supabase/client';
import { useRoleGuard } from '@/composables/useRoleGuard';
import { useLayout } from '@/layout/composables/layout';
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options';
import { primaryColors, surfaces, applyThemeEngine } from '@/theme/theme.options';
import { MELISSA_CONFIG_GRUPOS } from './composables/melissaConfigGrupos.js';
const props = defineProps({
secaoAtiva: { type: String, default: null }
@@ -35,7 +36,7 @@ const emit = defineEmits(['select', 'close']);
const router = useRouter();
const { role } = useRoleGuard();
const { layoutConfig, toggleDarkMode, isDarkTheme } = useLayout();
const { layoutConfig, isDarkTheme } = useLayout();
const { queuePatch } = useUserSettingsPersistence();
// ── Catálogo de categorias ────────────────────────────────────
@@ -146,29 +147,25 @@ const CATEGORIAS = [
label: 'Configurações',
icon: 'pi pi-cog',
color: '#94a3b8',
groups: [
{
title: 'Layout Melissa',
items: [
// Sem `route` — emit('select', 'aparencia') abre página interna do Melissa
{ key: 'aparencia', label: 'Layout Melissa', 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' } }
]
}
]
// Groups derivados do MELISSA_CONFIG_GRUPOS (fonte unica em
// composables/melissaConfigGrupos.js). Items SEM `route` —
// emit('select', key) dispara abrirSecao no MelissaLayout, que
// navega pra /melissa/<slug> e renderiza a pagina nativa
// (perfil/plano/etc) ou o MelissaConfiguracoes (cfg-* embeds).
//
// Grupo 'conta' filtrado: Meu Perfil/Plano/Negocio/Seguranca ja
// aparecem como atalhos fixos no rodape do mm-side, nao precisa
// duplicar aqui.
groups: MELISSA_CONFIG_GRUPOS
.filter((g) => g.key !== 'conta')
.map((g) => ({
title: g.label,
items: g.items.map((i) => ({
key: i.key,
label: i.label,
icon: i.icon
}))
}))
}
];
@@ -323,21 +320,6 @@ 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 {
toggleDarkMode();
// requestAnimationFrame pra garantir que a classe já foi aplicada
await new Promise((r) => requestAnimationFrame(r));
const after = document.documentElement.classList.contains('app-dark');
const theme_mode = after ? 'dark' : 'light';
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[MelissaMenu] toggleDark falhou:', e);
}
}
// ── Cores do Tema (embutido no aside, não em popover externo) ──
const themeViewActive = ref(false);
@@ -371,14 +353,6 @@ function setSurface(s) {
saveThemeToStorage();
}
function setPreset(p) {
if (!p || p === layoutConfig.preset) return;
layoutConfig.preset = p;
applyThemeEngine(layoutConfig);
queuePatch?.({ preset: p });
saveThemeToStorage();
}
function surfaceIsActive(s) {
if (layoutConfig.surface) return layoutConfig.surface === s.name;
// fallback default por mode
@@ -478,6 +452,10 @@ async function sair() {
<div class="mm-side__foot">
<!-- Itens grudados acima do usuário -->
<div class="mm-foot-actions">
<!-- Bloco "Conta": sempre visivel no rodape. Os mesmos
atalhos sao filtrados do aside direito quando a
categoria Configuracoes esta ativa, pra nao duplicar. -->
<div class="mm-foot-label">Conta</div>
<button
class="mm-foot-item"
:class="{ 'is-active': props.secaoAtiva === 'perfil' }"
@@ -506,20 +484,6 @@ async function sair() {
>
<i class="pi pi-shield" /><span>Segurança</span>
</button>
<button
class="mm-foot-item"
:class="{ 'is-active': isDarkTheme }"
:aria-pressed="isDarkTheme"
@click="toggleDarkAndPersist"
>
<i :class="isDarkTheme ? 'pi pi-sun' : 'pi pi-moon'" />
<span>Modo escuro</span>
<span
class="mm-toggle"
:class="{ 'is-on': isDarkTheme }"
aria-hidden="true"
/>
</button>
<button
class="mm-foot-item"
:class="{ 'is-active': themeViewActive }"
@@ -600,22 +564,10 @@ async function sair() {
</div>
</div>
<div class="mm-theme__group">
<div class="mm-theme__title">Preset</div>
<div class="mm-theme__presets">
<button
v-for="p in presetOptions"
:key="p"
type="button"
class="mm-theme__preset"
:class="{ 'is-active': layoutConfig.preset === p }"
@click="setPreset(p)"
>{{ p }}</button>
</div>
</div>
<div class="mm-theme__hint">
Mudanças se aplicam ao app inteiro e ficam salvas no seu perfil.
Para escolher o <strong>preset</strong> (Aura / Lara / Nora), use o
<i class="pi pi-cog" /> Personalizar no canto superior direito.
</div>
<!-- Cards informativos (sem interação ainda) -->
@@ -988,6 +940,14 @@ async function sair() {
padding-bottom: 6px;
border-bottom: 1px solid var(--m-border);
}
.mm-foot-label {
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-text-faint);
font-size: 0.62rem;
font-weight: 600;
padding: 4px 10px 4px;
}
.mm-foot-item {
width: 100%;
display: flex;
@@ -1037,35 +997,6 @@ async function sair() {
border-radius: 999px;
}
/* Switch on/off (modo escuro) — usa as cores do tema */
.mm-toggle {
flex: none !important;
width: 32px;
height: 18px;
background: var(--m-border-strong, var(--m-border));
border-radius: 999px;
position: relative;
transition: background-color 200ms ease;
}
.mm-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform 200ms ease;
}
.mm-toggle.is-on {
background: var(--p-primary-color);
}
.mm-toggle.is-on::after {
transform: translateX(14px);
}
/* User row */
.mm-user {
display: flex;
@@ -1347,39 +1278,22 @@ async function sair() {
box-shadow: 0 0 0 2px var(--m-bg-soft-hover);
transform: scale(1.08);
}
.mm-theme__presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mm-theme__preset {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 5px 11px;
border-radius: 8px;
font-size: 0.72rem;
font-family: inherit;
text-transform: capitalize;
cursor: pointer;
transition: all 140ms ease;
}
.mm-theme__preset:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mm-theme__preset.is-active {
background: var(--m-border-strong);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mm-theme__hint {
font-size: 0.7rem;
color: var(--m-text-faint);
line-height: 1.4;
line-height: 1.5;
padding-top: 6px;
border-top: 1px solid var(--m-border);
}
.mm-theme__hint strong {
color: var(--m-text-muted);
font-weight: 600;
}
.mm-theme__hint i {
color: var(--m-text-muted);
font-size: 0.72rem;
margin: 0 2px;
}
/* ─── Info cards (descritivos, sem interação ainda) ─────── */
.mm-info-card {
+537
View File
@@ -0,0 +1,537 @@
<script setup>
/*
* MelissaSettingsPanel — popover "Personalizar" do MelissaLayout
* --------------------------------------------------------------
* Painel glass que abre via cog no canto superior direito do
* Melissa. Concentra:
* - Plano de fundo (imagem custom, opacidades, voltar ao default)
* - Relogio & Som (formato 24h, toque de termino + botao testar)
* - Tema (modo escuro, preset Lara/Nora, cor primaria, surface)
*
* Estado e setters chegam via inject('melissaSettings') — mesma
* fonte usada por MelissaConfiguracoes (sem duplicar refs). Isso
* mantem persistencia (localStorage + DB) centralizada no pai.
*
* Estado local:
* - fileInput (template ref) + pickFile() — pura cola de DOM
*
* Emit:
* - close — botao X clicado
*/
import { inject, ref } from 'vue';
import { TOQUES } from './melissaToques';
const emit = defineEmits(['close']);
const settings = inject('melissaSettings', null);
if (!settings) {
// Fallback defensivo: nunca deveria acontecer (so se montarem
// este componente fora do MelissaLayout). Loga e segue com noop.
// eslint-disable-next-line no-console
console.warn('[MelissaSettingsPanel] inject melissaSettings ausente — montagem fora do MelissaLayout?');
}
const {
layoutConfig,
isDarkTheme,
activeSurface,
PRIMARY_COLORS,
SURFACES,
PRESETS,
setPrimary,
setSurface,
setPreset,
setDark,
bgUrl,
overlayOpacity,
bgImageOpacity,
onFileChange,
clearBg,
use24h,
toqueTermino,
testarToque
} = settings || {};
// Cola de DOM: input <type=file> escondido + trigger via botao "Trocar
// imagem". onFileChange (no pai) faz validacao de tipo/tamanho e ja
// dispara watchers de persistencia em bgUrl.
const fileInput = ref(null);
function pickFile() {
fileInput.value?.click();
}
</script>
<template>
<div class="glass-panel mp-panel absolute top-12 right-0 w-72">
<!-- Cabecalho fixo -->
<header class="mp-head">
<div class="mp-head__title">
<i class="pi pi-sliders-h" />
<span>Personalizar</span>
</div>
<button
class="mp-head__close"
title="Fechar"
aria-label="Fechar"
@click="emit('close')"
>
<i class="pi pi-times" />
</button>
</header>
<!-- Corpo scrollavel -->
<div class="mp-body">
<!-- Plano de fundo -->
<div class="mp-divider"><span>Plano de Fundo</span></div>
<button class="mp-btn mp-btn--primary" @click="pickFile">
<i class="pi pi-image" />
<span>Trocar imagem de fundo</span>
</button>
<p class="mp-hint">
Recomendado: 1920×1080 (Full HD), JPG ou PNG. Máximo 2&nbsp;MB.
</p>
<button
v-if="bgUrl"
class="mp-btn mp-btn--ghost"
@click="clearBg"
>
<i class="pi pi-refresh" />
<span>Voltar ao padrão</span>
</button>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
<div v-if="bgUrl" class="mp-field">
<label class="mp-label">
Transparência da imagem
<span class="mp-label__value">{{ Math.round(bgImageOpacity * 100) }}%</span>
</label>
<input
v-model.number="bgImageOpacity"
type="range"
min="0.01"
max="1"
step="0.01"
class="settings-range w-full"
/>
</div>
<div class="mp-field">
<label class="mp-label">
Opacidade do fundo
<span class="mp-label__value">{{ Math.round(overlayOpacity * 100) }}%</span>
</label>
<input
v-model.number="overlayOpacity"
type="range"
min="0"
max="0.8"
step="0.05"
class="settings-range w-full"
/>
</div>
<!-- Relogio & Toques -->
<div class="mp-divider"><span>Relógio &amp; Som</span></div>
<div class="mp-row">
<span class="mp-row__label">
Formato 24h
<span class="mp-row__sub">(relógio)</span>
</span>
<button
class="settings-toggle w-10 h-6 rounded-full transition-colors relative"
:class="use24h ? 'is-on' : 'bg-white/20'"
@click="use24h = !use24h"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: use24h ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<div class="mp-field">
<label class="mp-label">Toque de término</label>
<div class="flex gap-1.5">
<select v-model="toqueTermino" class="settings-select flex-1">
<option v-for="t in TOQUES" :key="t.id" :value="t.id">
{{ t.label }}
</option>
</select>
<button
class="settings-test-btn"
title="Testar"
:disabled="toqueTermino === 'nenhum'"
@click="testarToque"
>
<i class="pi pi-play text-[0.65rem]" />
</button>
</div>
</div>
<!-- Tema -->
<div class="mp-divider"><span>Tema</span></div>
<div class="mp-row">
<span class="mp-row__label">Modo escuro</span>
<button
class="settings-toggle w-10 h-6 rounded-full transition-colors relative"
:class="isDarkTheme ? 'is-on' : 'bg-white/20'"
:title="isDarkTheme ? 'Mudar pra claro' : 'Mudar pra escuro'"
@click="setDark(!isDarkTheme)"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: isDarkTheme ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<div class="mp-field">
<label class="mp-label">Preset</label>
<div class="mp-presets">
<button
v-for="p in PRESETS"
:key="p"
type="button"
class="mp-preset"
:class="{ 'is-active': layoutConfig.preset === p }"
@click="setPreset(p)"
>{{ p }}</button>
</div>
</div>
<div class="mp-field">
<label class="mp-label">Cor primária</label>
<div class="grid grid-cols-9 gap-1.5">
<button
v-for="pc in PRIMARY_COLORS"
:key="pc.name"
class="settings-swatch"
:class="{ 'is-active': layoutConfig.primary === pc.name }"
:style="{ backgroundColor: pc.swatch === 'currentColor' ? 'var(--m-text)' : pc.swatch }"
:title="pc.name"
@click="setPrimary(pc.name)"
/>
</div>
</div>
<div class="mp-field">
<label class="mp-label">Surface</label>
<div class="grid grid-cols-8 gap-1.5">
<button
v-for="sf in SURFACES"
:key="sf.name"
class="settings-swatch"
:class="{ 'is-active': activeSurface === sf.name }"
:style="{ backgroundColor: sf.palette['500'] }"
:title="sf.name"
@click="setSurface(sf.name)"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* glass-panel base — redeclarado aqui porque o do pai esta em
<style scoped> e nao atravessa o limite do componente. Os
overrides de light mode (html:not(.app-dark) .win11-root ...)
estao em <style> nao-scoped do pai e continuam aplicando. */
.glass-panel {
background: var(--m-bg-soft);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
/* ─── Painel "Personalizar" (mp-*) ────────────────────────────
Cores via vars (--m-text, --m-border, etc) pra adaptar light
e dark — em light o glass-panel ganha fundo claro e texto
branco hardcoded ficava invisivel.
Estrutura: header fixo + body scrollavel, secoes separadas
por .mp-divider (linha + label inline):
- Plano de Fundo (imagem, opacidades)
- Relogio & Som (24h, toque)
- Tema (modo escuro, preset, primaria, surface)
─────────────────────────────────────────────────────────── */
.mp-panel {
display: flex;
flex-direction: column;
padding: 0;
max-height: calc(100vh - 5rem);
overflow: hidden;
color: var(--m-text);
}
.mp-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
}
.mp-head__title {
display: flex;
align-items: center;
gap: 8px;
color: var(--m-text);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.mp-head__title > i {
color: var(--p-primary-color);
font-size: 0.9rem;
}
.mp-head__close {
width: 26px;
height: 26px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 7px;
cursor: pointer;
font-size: 0.7rem;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.mp-head__close:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
border-color: var(--m-border-strong);
}
.mp-body {
flex: 1;
overflow-y: auto;
padding: 12px 14px 16px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mp-body::-webkit-scrollbar { width: 5px; }
.mp-body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Divider com label inline — separa secoes do painel */
.mp-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 14px 0 10px;
color: var(--m-text-muted);
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.16em;
}
.mp-divider:first-child {
margin-top: 2px;
}
.mp-divider::before,
.mp-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--m-border);
}
.mp-divider > span {
flex-shrink: 0;
}
/* Botoes do painel */
.mp-btn {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 9px;
font-size: 0.82rem;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
text-align: left;
}
.mp-btn--primary {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
}
.mp-btn--primary:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mp-btn--ghost {
background: transparent;
border: 1px solid transparent;
color: var(--m-text-muted);
padding: 6px 12px;
margin-bottom: 8px;
}
.mp-btn--ghost:hover {
background: var(--m-bg-soft);
color: var(--m-text);
}
.mp-btn > i {
font-size: 0.85rem;
flex-shrink: 0;
}
/* Hint pequeno abaixo de botao */
.mp-hint {
font-size: 0.66rem;
line-height: 1.4;
color: var(--m-text-faint);
padding: 0 4px;
margin: 4px 0 8px;
}
/* Field generico (label + input/range/select abaixo) */
.mp-field {
margin-bottom: 12px;
}
.mp-label {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--m-text-muted);
font-size: 0.72rem;
margin-bottom: 6px;
}
.mp-label__value {
color: var(--m-text);
font-variant-numeric: tabular-nums;
font-size: 0.7rem;
}
/* Row (label esq + controle dir, mesma linha) */
.mp-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 0.82rem;
}
.mp-row__label {
color: var(--m-text);
}
.mp-row__sub {
color: var(--m-text-faint);
font-size: 0.7rem;
margin-left: 4px;
}
/* Preset chips (Aura / Lara / Nora) */
.mp-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mp-preset {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 5px 12px;
border-radius: 8px;
font-size: 0.74rem;
font-family: inherit;
text-transform: capitalize;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mp-preset:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mp-preset.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, white);
}
/* ─── Settings popover: select + botao testar do toque ────── */
.settings-select {
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
padding: 7px 10px;
border-radius: 8px;
font-size: 0.8rem;
outline: none;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
min-width: 0;
}
.settings-select:hover {
background: var(--m-bg-soft-hover);
}
.settings-select option {
/* renderizado pelo OS — usa tokens semanticos pra acompanhar dark/light */
background: var(--p-content-background);
color: var(--m-text);
}
.settings-test-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
transition: background-color 140ms ease;
}
.settings-test-btn:hover {
background: var(--m-accent);
border-color: var(--m-accent);
}
.settings-test-btn:disabled {
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-faint);
cursor: not-allowed;
}
/* Sliders do painel — accent-color pinta thumb + parte preenchida na
primary. Sem sobrescrever ::-webkit-slider-track (Chrome desce a thumb
se a track for estilizada). */
.settings-range {
accent-color: var(--p-primary-color);
width: 100%;
}
/* Toggle "ligado" usa a primary do preset escolhido */
.settings-toggle.is-on {
background-color: var(--p-primary-color);
}
/* Swatches de cor primaria — circulos compactos com ring na ativa */
.settings-swatch {
width: 22px;
height: 22px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
padding: 0;
}
.settings-swatch:hover {
transform: scale(1.12);
}
.settings-swatch.is-active {
box-shadow:
0 0 0 2px var(--m-bg-medium),
0 0 0 4px var(--p-primary-color);
}
</style>
+814
View File
@@ -0,0 +1,814 @@
<script setup>
/*
* MelissaTimelineHoje — linha do tempo "Hoje" do MelissaLayout
* ------------------------------------------------------------
* Mostra os eventos do dia em duas modalidades responsivas:
* - Horizontal (lg+): scroll horizontal com pilulas posicionadas
* por hora, eco lateral pra eventos off-screen, cursor "Agora"
* e auto-scroll inicial centrado no horario atual
* - Vertical (< lg): tipo calendario "dia" com slots de 48px/h,
* pilulas absolutas e cursor "Agora" tipo Google Calendar
*
* Tudo deriva-se das props (range de horas, eco, posicoes, status).
* Nao acessa state externo direto — eh um componente puro de
* apresentacao + interacoes locais (scroll, eco-click).
*
* Props:
* - eventos: Array — raw eventosHojeReais (passa direto)
* - now: Date — relogio atual (atualizado pelo pai a cada 1s)
* - workRules: Array — regras semanais da agenda (filtra por dow)
* - agendaSettings: Object|null — config da agenda (range fallback)
* - feriados: Array — feriados do mes (procura entry pra hoje)
* - filtroTipo: String|null — filtro ativo (sessao/supervisao/reuniao)
*
* Emit:
* - evento(ev) — clique numa pilula (pai abre dialog)
* - clear-filter — clique no X do chip de filtro
*/
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
eventos: { type: Array, default: () => [] },
now: { type: Date, required: true },
workRules: { type: Array, default: () => [] },
agendaSettings: { type: Object, default: null },
feriados: { type: Array, default: () => [] },
filtroTipo: { type: String, default: null }
});
const emit = defineEmits(['evento', 'clear-filter']);
// ───────────────────────────────────────────────────────────────
// Range de horas (HORA_INICIO/HORA_FIM) — derivado de:
// 1. workRules do dia da semana atual (se houver)
// 2. agendaSettings (fallback global 0818h)
// Range expande pra incluir eventos fora do expediente — sessao
// excepcional nao some da timeline.
// ───────────────────────────────────────────────────────────────
function _timeStrToHour(s, fallback) {
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 fallback;
}
const todayRules = computed(() => {
const dow = props.now.getDay(); // 0=dom .. 6=sab
return (props.workRules || []).filter((r) => r.dia_semana === dow && r.ativo !== false);
});
const isFolga = computed(() => todayRules.value.length === 0);
const todayFeriado = computed(() => {
const d = props.now;
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return (props.feriados || []).find((f) => f.data === k) || null;
});
function _baseRange() {
const rules = todayRules.value;
if (rules.length > 0) {
const starts = rules.map((r) => _timeStrToHour(r.hora_inicio, 8));
const ends = rules.map((r) => _timeStrToHour(r.hora_fim, 18));
return { start: Math.min(...starts), end: Math.max(...ends) };
}
const s = props.agendaSettings;
const fbStart = (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '08:00';
const fbEnd = (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '18:00';
return { start: _timeStrToHour(fbStart, 8), end: _timeStrToHour(fbEnd, 18) };
}
const HORA_INICIO = computed(() => {
const { start } = _baseRange();
const minEv = props.eventos.length ? Math.min(...props.eventos.map((e) => e.startH)) : Infinity;
return Math.max(0, Math.floor(Math.min(start, minEv)));
});
const HORA_FIM = computed(() => {
const { end } = _baseRange();
const maxEv = props.eventos.length ? Math.max(...props.eventos.map((e) => e.endH)) : -Infinity;
return Math.min(24, Math.ceil(Math.max(end, maxEv)));
});
const hoursRange = computed(() => {
const arr = [];
for (let h = HORA_INICIO.value; h <= HORA_FIM.value; h++) arr.push(h);
return arr;
});
// ───────────────────────────────────────────────────────────────
// Filtro + lista visivel + label do chip
// ───────────────────────────────────────────────────────────────
const eventosVisiveis = computed(() => {
if (!props.filtroTipo) return props.eventos;
return props.eventos.filter((ev) => ev.tipo === props.filtroTipo);
});
const filtroLabel = computed(() => {
if (!props.filtroTipo) return '';
const map = { sessao: 'atendimentos', supervisao: 'supervisões', reuniao: 'reuniões' };
return map[props.filtroTipo] || '';
});
// ───────────────────────────────────────────────────────────────
// Helpers de status (cor, icone, em-curso) usados em pilulas
// ───────────────────────────────────────────────────────────────
function statusKey(ev) {
const s = String(ev?.status || '').toLowerCase();
if (s === 'realizada') return 'realizado';
if (s === 'cancelada') return 'cancelado';
return s || 'agendado';
}
function statusIcon(ev) {
const s = statusKey(ev);
if (s === 'realizado') return 'pi pi-check';
if (s === 'faltou') return 'pi pi-times';
if (s === 'cancelado') return 'pi pi-ban';
if (s === 'remarcar') return 'pi pi-refresh';
return null;
}
function isEvEmCurso(ev) {
const s = statusKey(ev);
if (s === 'realizado' || s === 'cancelado' || s === 'faltou') return false;
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
return typeof ev?.startH === 'number' && typeof ev?.endH === 'number'
&& h >= ev.startH && h < ev.endH;
}
function pillStatusClass(ev) {
const s = statusKey(ev);
return [
`tl-pill--${s}`,
{ 'tl-pill--em-curso': isEvEmCurso(ev) }
];
}
// ───────────────────────────────────────────────────────────────
// Posicionamento das pilulas (horizontal + vertical) e cursor
// ───────────────────────────────────────────────────────────────
function eventStyle(ev) {
const total = HORA_FIM.value - HORA_INICIO.value;
const left = ((ev.startH - HORA_INICIO.value) / total) * 100;
const width = ((ev.endH - ev.startH) / total) * 100;
return {
left: `${left}%`,
width: `${width}%`,
backgroundColor: ev.color,
// Expoe a cor pra CSS (glow/pulse usa color-mix com essa var)
'--ev-color': ev.color
};
}
const VT_HOUR_PX = 48; // altura de cada slot de hora em px
function eventStyleVertical(ev) {
return {
top: `${(ev.startH - HORA_INICIO.value) * VT_HOUR_PX}px`,
height: `${(ev.endH - ev.startH) * VT_HOUR_PX}px`,
backgroundColor: ev.color,
'--ev-color': ev.color
};
}
const nowCursorLeft = computed(() => {
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%';
const total = HORA_FIM.value - HORA_INICIO.value;
return `${((h - HORA_INICIO.value) / total) * 100}%`;
});
const nowCursorTop = computed(() => {
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%';
return `${(h - HORA_INICIO.value) * VT_HOUR_PX}px`;
});
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')}`;
}
// ───────────────────────────────────────────────────────────────
// Scroll/eco do horizontal — minimap pulsante de cores nas bordas
// ───────────────────────────────────────────────────────────────
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 = HORA_FIM.value - HORA_INICIO.value;
const empty = { left: [], right: [], vStart: HORA_INICIO.value, vEnd: HORA_FIM.value };
if (total <= 0 || !innerW || !viewW || innerW <= viewW) return empty;
const vStart = HORA_INICIO.value + (scrollL / innerW) * total;
const vEnd = HORA_INICIO.value + ((scrollL + viewW) / innerW) * total;
const left = [];
const right = [];
for (const ev of eventosVisiveis.value) {
if (ev.endH <= vStart) left.push(ev);
else if (ev.startH >= vEnd) right.push(ev);
}
return { left, right, vStart, vEnd };
});
// Esquerda: HORA_INICIO -> vStart mapeado 0-100%.
// Direita: vEnd -> HORA_FIM mapeado 0-100%.
function ecoTickStyle(ev, side) {
const { vStart, vEnd } = tlEcoState.value;
let topPct = 50;
if (side === 'left') {
const span = vStart - HORA_INICIO.value;
if (span > 0) topPct = ((ev.startH - HORA_INICIO.value) / span) * 100;
} else {
const span = HORA_FIM.value - vEnd;
if (span > 0) topPct = ((ev.startH - vEnd) / span) * 100;
}
return {
top: `${Math.max(0, Math.min(100, topPct))}%`,
backgroundColor: ev.color
};
}
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 = HORA_FIM.value - HORA_INICIO.value;
if (total <= 0) return;
const ratio = (ev.startH - HORA_INICIO.value) / total;
const target = Math.max(0, Math.min(innerWidth - visibleWidth, ratio * innerWidth - visibleWidth / 2));
el.scrollTo({ left: target, behavior: 'smooth' });
}
// ───────────────────────────────────────────────────────────────
// Auto-scroll inicial: centra "agora" na viewport ao montar (jornadas
// longas ex: 02h-23h nao abrem com cursor off-screen). Roda uma vez.
// ResizeObserver mantem o eco atualizado quando viewport muda.
// ───────────────────────────────────────────────────────────────
let _tlAutoScrolled = false;
function _scrollTimelineToNow() {
const el = tlHScrollEl.value;
if (!el) return;
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
const total = HORA_FIM.value - HORA_INICIO.value;
if (total <= 0 || h < HORA_INICIO.value || h > HORA_FIM.value) return;
const inner = el.firstElementChild;
if (!inner) return;
const innerWidth = inner.scrollWidth || inner.offsetWidth;
const visibleWidth = el.clientWidth;
if (innerWidth <= visibleWidth) return; // sem overflow, nada a rolar
const ratio = (h - HORA_INICIO.value) / total;
el.scrollLeft = Math.max(0, ratio * innerWidth - visibleWidth / 2);
}
onMounted(() => {
const stop = watch(
[HORA_INICIO, HORA_FIM],
() => {
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());
}
});
</script>
<template>
<section class="glass-panel mt-8 px-5 py-4 max-w-5xl w-full mx-auto">
<!-- Header: titulo, badges (folga/feriado), chip de filtro, "Agora" -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 text-white/90 text-sm font-medium">
<i class="pi pi-clock text-xs" />
Linha do tempo Hoje
<span
v-if="todayFeriado"
class="tl-day-badge tl-day-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="tl-day-badge tl-day-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>
<button
v-if="filtroTipo"
type="button"
class="filtro-chip"
:title="`Mostrando apenas ${filtroLabel}. Clique pra mostrar tudo.`"
@click="emit('clear-filter')"
>
{{ filtroLabel }}
<i class="pi pi-times text-[0.6rem]" />
</button>
</div>
<div class="text-xs text-white/70 flex items-center gap-1.5">
<span class="pulse-dot w-2.5 h-0.5 rounded-full bg-red-500" />
Agora
</div>
</div>
<!-- Horizontal (lg+) scroll horizontal com pilulas + cursor "agora" + eco lateral.
Auto-scroll inicial centra "agora" pra jornadas longas (02h-23h). Frame
relativo abriga o eco como overlay absolute. -->
<div class="tl-h-frame hidden lg:block">
<div ref="tlHScrollEl" class="tl-h-scroll" @scroll.passive="onTimelineScroll">
<div class="tl-h-inner relative" :style="{ '--m-tl-cols': HORA_FIM - HORA_INICIO }">
<div class="flex justify-between mb-1">
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
<span class="text-[0.65rem] text-white/55 font-medium">{{ h }}h</span>
</div>
</div>
<div class="relative h-9 bg-white/5 rounded-md overflow-visible border border-white/10">
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="tl-event-pill absolute h-[30px] rounded flex items-center px-2 overflow-hidden cursor-pointer min-w-[32px] hover:brightness-110 transition-[filter,opacity] duration-200 z-10"
:class="pillStatusClass(ev)"
:style="eventStyle(ev)"
:title="ev.label"
@click="emit('evento', ev)"
>
<span class="tl-event-pill__label text-[0.8rem] font-semibold truncate">{{ ev.label }}</span>
<i
v-if="statusIcon(ev)"
:class="['tl-event-pill__status', statusIcon(ev)]"
aria-hidden="true"
/>
</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-90" />
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
</div>
</div>
</div>
</div>
<!-- Eco lateral minimap pulsante de cores. Tracinhos posicionados
por tempo, mostrando "forma" do dia off-screen. Click = scroll
suave ate o evento. -->
<div
v-if="tlEcoState.left.length"
class="tl-eco 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="tl-eco__tick"
:style="ecoTickStyle(ev, 'left')"
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
@click="scrollToEvent(ev)"
/>
</div>
<div
v-if="tlEcoState.right.length"
class="tl-eco 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="tl-eco__tick"
:style="ecoTickStyle(ev, 'right')"
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
@click="scrollToEvent(ev)"
/>
</div>
</div>
<!-- Vertical (< lg) calendario "dia" empilhado. Slots de 48px/h. -->
<div class="vt lg:hidden" :style="{ '--m-vt-rows': HORA_FIM - HORA_INICIO }">
<div
v-for="h in hoursRange"
:key="h"
class="vt-row"
:style="{ top: `${(h - HORA_INICIO) * VT_HOUR_PX}px` }"
>
<span class="vt-hour">{{ h }}h</span>
<div class="vt-line" />
</div>
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="vt-event hover:brightness-110 transition-[filter,opacity] duration-200"
:class="pillStatusClass(ev)"
:style="eventStyleVertical(ev)"
:title="ev.label"
@click="emit('evento', ev)"
>
<i
v-if="statusIcon(ev)"
:class="['vt-event__status', statusIcon(ev)]"
aria-hidden="true"
/>
<div class="vt-event-time">
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
</div>
<div class="vt-event-label">{{ ev.label }}</div>
</div>
<div class="vt-now" :style="{ top: nowCursorTop }">
<div class="vt-now-dot" />
<div class="vt-now-line" />
</div>
</div>
</section>
</template>
<style scoped>
/* glass-panel base — redeclarado scoped (o do pai esta em scoped tambem
e nao atravessa). Overrides light mode (.win11-root :is(.glass-panel...))
estao em <style> nao-scoped do pai e continuam aplicando. */
.glass-panel {
background: var(--m-bg-soft);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
/* ─── Chip do filtro ativo (header da timeline) ────────────── */
.filtro-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
padding: 2px 8px 2px 10px;
border-radius: 9999px;
background: var(--m-bg-soft-hover);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: background-color 140ms ease;
}
.filtro-chip:hover {
background: var(--m-border-strong);
}
/* ─── Badge "Folga" / "Feriado" ──────────────────────────────
Sinaliza dia nao-util sem bloquear: sessoes fora do expediente
continuam permitidas e visiveis na timeline. */
.tl-day-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
}
.tl-day-badge--folga {
background: var(--m-bg-soft-hover);
color: var(--m-text-muted);
border: 1px solid var(--m-border-strong);
}
.tl-day-badge--feriado {
background: color-mix(in srgb, rgb(245, 158, 11) 18%, transparent);
color: rgb(245, 158, 11);
border: 1px solid color-mix(in srgb, rgb(245, 158, 11) 38%, transparent);
}
/* Light mode: amber-600 saturado ficaria estridente; baixa pra amber-700
sobre amber-50 — legivel em fundo claro e mantem a identidade do feriado.
Em scoped CSS, ancestor `html:not(.app-dark)` nao recebe o hash do
componente — so o leaf `.tl-day-badge--feriado` recebe; o selector
funciona normalmente. */
html:not(.app-dark) .tl-day-badge--feriado {
background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent);
color: rgb(180, 83, 9);
border-color: color-mix(in srgb, rgb(217, 119, 6) 32%, transparent);
}
/* ─── Timeline vertical (< lg) — tipo calendario "dia" ─────── */
.vt {
position: relative;
/* --m-vt-rows = HORA_FIM - HORA_INICIO (set inline; fallback 12 = 8-20) */
height: calc(var(--m-vt-rows, 12) * 48px + 24px);
margin-top: 0.75rem;
}
.vt-row {
position: absolute;
left: 0;
right: 0;
height: 0;
display: flex;
align-items: center;
}
.vt-hour {
width: 36px;
text-align: right;
padding-right: 8px;
color: var(--m-text-muted);
font-size: 0.65rem;
font-weight: 500;
flex-shrink: 0;
/* Texto centrado na linha (transform shift up half) */
transform: translateY(-50%);
background: transparent;
line-height: 1;
}
.vt-line {
flex: 1;
height: 1px;
background: var(--m-bg-soft);
}
.vt-event {
position: absolute;
left: 44px;
right: 4px;
padding: 4px 10px;
border-radius: 4px;
color: white;
cursor: pointer;
overflow: hidden;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 24px;
}
.vt-event-time {
font-size: 0.62rem;
opacity: 0.85;
line-height: 1.1;
}
.vt-event-label {
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
/* ─── Pilula (horizontal) — label sempre branco sobre cor saturada.
Override .text-white global flipava em light mode pra dark e quebrava
contraste sobre indigo/verde/vermelho — forcar branco aqui. */
.tl-event-pill__label {
color: #fff;
flex: 1;
min-width: 0;
}
/* ─── Status overlays nas pilulas (horizontal + vertical) ──────
Cada status ganha tratamento especifico — icone no canto + variacao
de opacidade/borda/hatch. var --ev-color (set no inline style do
eventStyle) alimenta o pulse "em curso" com a cor do proprio evento. */
.tl-event-pill {
transition: filter 200ms ease, opacity 200ms ease, box-shadow 240ms ease;
}
.tl-event-pill__status,
.vt-event__status {
flex-shrink: 0;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.28);
color: #fff;
font-size: 0.55rem;
margin-left: 6px;
line-height: 1;
}
.vt-event__status {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
font-size: 0.6rem;
margin-left: 0;
}
/* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */
.tl-pill--realizado {
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
}
/* Faltou: opacidade reduzida + label tachado */
.tl-pill--faltou {
opacity: 0.78;
}
.tl-pill--faltou .tl-event-pill__label,
.tl-pill--faltou .vt-event-label {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.55);
text-decoration-thickness: 1.5px;
}
/* Cancelado: hatching diagonal + opacidade + label tachado */
.tl-pill--cancelado {
opacity: 0.6;
background-image: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.0) 0,
rgba(255, 255, 255, 0.0) 4px,
rgba(255, 255, 255, 0.22) 4px,
rgba(255, 255, 255, 0.22) 6px
);
}
.tl-pill--cancelado .tl-event-pill__label,
.tl-pill--cancelado .vt-event-label {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.5);
}
/* Remarcar: ring ambar puxa atencao (status transiente, precisa decisao) */
.tl-pill--remarcar {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.6), 0 4px 14px rgba(245, 158, 11, 0.25);
}
/* Em curso: pulse glow na cor do proprio evento via --ev-color */
.tl-pill--em-curso {
animation: tl-pill-em-curso 2.2s ease-in-out infinite;
z-index: 12;
}
@keyframes tl-pill-em-curso {
0%, 100% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--ev-color, #6366f1) 55%, transparent),
0 0 0 1px rgba(255, 255, 255, 0.14);
}
50% {
box-shadow:
0 0 0 6px color-mix(in srgb, var(--ev-color, #6366f1) 0%, transparent),
0 0 18px 2px color-mix(in srgb, var(--ev-color, #6366f1) 60%, transparent);
}
}
/* ─── Timeline horizontal: scroll quando o range eh grande ────
--m-tl-cols (set inline) = HORA_FIM - HORA_INICIO
--m-tl-slot-w = largura minima por hora (default 80px). */
.tl-h-scroll {
overflow-x: auto;
overflow-y: visible;
/* respiro pra pilulas e cursor "Agora" nao cortarem na borda */
padding-bottom: 4px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.tl-h-scroll::-webkit-scrollbar { height: 6px; }
.tl-h-scroll::-webkit-scrollbar-track { background: transparent; }
.tl-h-scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 9999px;
}
.tl-h-inner {
min-width: calc(var(--m-tl-cols, 12) * var(--m-tl-slot-w, 80px));
}
/* ─── Eco lateral: minimap pulsante de cores nas bordas ────────
Faixas verticais de 8px coladas nas bordas do scroll, mostrando
tracinhos coloridos (cor do status) pra cada evento off-screen. */
.tl-h-frame { position: relative; }
.tl-eco {
position: absolute;
top: 16px; /* alinha com topo da barra (descontando linha de horas) */
bottom: 8px; /* respiro do scrollbar */
width: 8px;
z-index: 6;
pointer-events: auto;
border-radius: 4px;
background: color-mix(in srgb, var(--m-bg-soft) 70%, transparent);
border: 1px solid var(--m-border);
box-shadow: 0 0 0 0 transparent;
animation: tl-eco-pulse 2400ms ease-in-out infinite;
transition: opacity 180ms ease;
}
.tl-eco--left { left: -2px; }
.tl-eco--right { right: -2px; }
.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;
}
.tl-eco__tick:hover {
opacity: 1;
height: 6px;
transform: translateY(-50%) scaleX(2.2);
z-index: 1;
}
.tl-eco--left .tl-eco__tick:hover { transform-origin: left center; }
.tl-eco--right .tl-eco__tick:hover { transform-origin: right center; }
@keyframes tl-eco-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--p-primary-color) 0%, transparent);
}
50% {
box-shadow: 0 0 10px 1px color-mix(in srgb, var(--p-primary-color) 35%, transparent);
}
}
/* Light mode: faixa precisa contrastar mais com bloom claro */
html:not(.app-dark) .tl-eco {
background: color-mix(in srgb, var(--m-bg-soft-hover) 90%, transparent);
border-color: var(--m-border-strong);
}
/* ─── Cursor "Agora" do vertical (mobile) ─────────────────── */
.vt-now {
position: absolute;
left: 32px;
right: 0;
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
transform: translateY(-50%);
}
.vt-now-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
flex-shrink: 0;
animation: pulse 1.6s ease-in-out infinite;
}
.vt-now-line {
flex: 1;
height: 2px;
background: rgb(239, 68, 68);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
margin-left: 2px;
}
/* ─── Pulse no "Agora" (header) ────────────────────────────── */
.pulse-dot {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
@@ -0,0 +1,41 @@
/*
* useMelissaToques — preferencia de toque de termino do Melissa
* -------------------------------------------------------------
* Encapsula apenas a preferencia (qual toque tocar) e o botao de
* teste do painel Personalizar. NAO controla o cronometro em si —
* o componente <MelissaCronometro> recebe `toque-termino` como prop
* e dispara o som ao final da sessao com a propria logica.
*
* Estado:
* - toqueTermino: string — id do toque selecionado (default 'sino')
*
* Acao:
* - testarToque(): toca o toque selecionado (preview no Personalizar)
*
* Constante exportada:
* - TOQUE_IDS: Set<string> — ids validos, usado pra sanitizar payload
* vindo de localStorage/DB no MelissaLayout
*
* Persistencia: NAO eh responsabilidade deste composable. O pai
* (MelissaLayout) persiste `toqueTermino` junto com outras prefs em
* user_settings.melissa_prefs.
*/
import { ref } from 'vue';
import { TOQUES, playToque } from '../melissaToques';
export const TOQUE_IDS = new Set(TOQUES.map((t) => t.id));
export function useMelissaToques(initialId = 'sino') {
// Sanitiza o default — se passarem id invalido cai pro 'sino'
const safeInitial = TOQUE_IDS.has(initialId) ? initialId : 'sino';
const toqueTermino = ref(safeInitial);
function testarToque() {
playToque(toqueTermino.value);
}
return {
toqueTermino,
testarToque
};
}
@@ -0,0 +1,94 @@
/*
* useMelissaWallpaper — wallpaper/background do MelissaLayout
* -----------------------------------------------------------
* Encapsula o estado e as operacoes do plano de fundo:
* - bgUrl: data URL da imagem custom (vazio = usa gradiente default)
* - overlayOpacity: 00.8, escurecedor sobre o bg (sempre aplicado)
* - bgImageOpacity: 0.011, transparencia da foto custom (so quando bgUrl)
*
* Operacoes:
* - onFileChange(e): valida tipo + tamanho, gera data URL
* - clearBg(): zera bgUrl pra voltar ao gradiente default
*
* Estilos prontos:
* - defaultBgStyle: gradiente bloom radial + linear, sempre renderizado
* atras de tudo (cores via CSS vars que flipam com dark/light)
* - photoStyle: computed que liga url(bgUrl) + opacity(bgImageOpacity)
*
* Persistencia: NAO eh responsabilidade deste composable. O pai
* (MelissaLayout) persiste estes refs junto com outras prefs em
* localStorage + user_settings.melissa_prefs.
*/
import { ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
// Limite de upload — protege quota do localStorage (~5MB) e evita data URL
// gigante atravessando a UI. JPG/PNG 1920×1080 cabe folgado nesse teto.
export const MAX_BG_BYTES = 2 * 1024 * 1024; // 2 MB
// Gradiente default — sempre renderizado no .win11-root (atras de tudo).
// Quando o user faz upload, .win11-photo aparece por cima com opacidade
// controlada pelo slider — permite blend natural com o gradiente abaixo.
// Cores vem de CSS vars que flipam com dark/light AND seguem o preset
// (ver style global no MelissaLayout: --bloom-c1/c2/base-1/base-2).
export const defaultBgStyle = Object.freeze({
backgroundImage:
'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)',
backgroundSize: 'cover'
});
export function useMelissaWallpaper() {
const toast = useToast();
const bgUrl = ref(''); // vazio = usa gradiente default
const overlayOpacity = ref(0.35); // 00.8 — escurecedor sobre o bg
const bgImageOpacity = ref(1); // 0.011 — transparencia da foto custom
function onFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.add({
severity: 'warn',
summary: 'Formato inválido',
detail: 'Selecione um arquivo de imagem (JPG, PNG, WEBP).',
life: 4000
});
e.target.value = '';
return;
}
if (file.size > MAX_BG_BYTES) {
toast.add({
severity: 'warn',
summary: 'Imagem muito grande',
detail: 'Máximo 2 MB. Reduza a resolução ou compressão e tente novamente.',
life: 4500
});
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (ev) => (bgUrl.value = ev.target.result);
reader.readAsDataURL(file);
}
function clearBg() {
bgUrl.value = '';
}
const photoStyle = computed(() => ({
backgroundImage: bgUrl.value ? `url(${bgUrl.value})` : 'none',
opacity: bgImageOpacity.value
}));
return {
bgUrl,
overlayOpacity,
bgImageOpacity,
MAX_BG_BYTES,
defaultBgStyle,
photoStyle,
onFileChange,
clearBg
};
}