Layout Melissa (Direção B): preview, /profile, Agenda, dock, cadastro
Sandbox completo do novo layout Win11 lockscreen-style. Não troca o
AppLayout atual — Fase 5 (router wire-up) fica pra sessão dedicada.
Estrutura
- src/layout/melissa/ — MelissaLayout (bg+ψ+overlays), MelissaCronometro,
MelissaAgenda (fullscreen), MelissaCard, MelissaMenu, MelissaBusca
- composables/useMelissaEventos.js — semana real do FC + range mensal
pros dots do mini-cal
- composables/useMelissaPacientes.js — agora retorna created_at p/ "novo"
- melissaToques.js — toques Web Audio do término
Rota e persistência
- /preview/melissa (sem auth, sem AppLayout)
- /account/profile ganha 3º card "Melissa" com badge "Em construção"
- bootstrapUserSettings + layout composable aceitam variant='melissa'
- Migration: CHECK constraint user_settings.layout_variant aceita 'melissa'
Light mode
- Gradiente Bloom flipa via CSS vars (--bloom-c1/c2/base-1/base-2)
Dark: 400/300/950 · Light: 200/100/0
- Cronômetro/Personalização: color: white → var(--m-text)
- Pílula psi-kbd ganha tokens --m-kbd-bg/--m-kbd-text
- Override mapeia text-X-200/300/400 → text-X-600 (17 cores Tailwind)
Agenda fullscreen
- Mini-cal funcional: click pula FC, range visível destacado, dots reais
- Feriados nacional/municipal/personalizado (rose/amber/violet)
- Dias fechados (workRules) cinza apagado, mutex feriado vence
- Card "Hoje" (stats+sessões) mesclado e movido pra sidebar esquerda
- ProximosFeriadosCard reaproveitado entre mini-cal e Hoje
- Avatar paciente: bg --m-accent-strong → --m-accent (saturado em light)
- Cores light: 12 substituições color:white → var(--m-text)
Dock taskbar Win11-style
- .melissa-dock 76px fixed bottom (CSS global, não scoped — Vue static
hoisting perderia data-v-{hash})
- ψ centralizado vertical na faixa (bottom:10px)
- Chip cronômetro teleportado pro dock + animação minimize macOS
(dialog encolhe + voa pro canto bottom-left, 340ms cubic-bezier)
- transform-origin: 96px calc(100% - 38px) (posição do chip no dock)
Pacientes na sidebar
- Botão fake "+" no topo abre PatientCreatePopover (rápido/completo/link)
- Reaproveita PatientCadastroDialog + ComponentCadastroRapido
- Pacientes criados nos últimos 7d sobem pro topo + badge "novo"
Dock contextual (ações do paciente selecionado)
- Avatar + nome + count + 5 ações (sessões/whatsapp/prontuário/editar/fechar)
- Teleportado pro .melissa-dock quando há paciente selecionado
- Em mobile, ações vivem em <Menu> kebab por linha
- Pattern <Transition><Teleport v-if> obrigatório (NUNCA o contrário)
pra evitar comment placeholder + emitsOptions:null no reconciler
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,9 @@ evolution-api/
|
|||||||
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
||||||
database-novo/backups/
|
database-novo/backups/
|
||||||
|
|
||||||
|
# Rascunhos de design locais (Melissa Direção A, etc)
|
||||||
|
layout-scratchs/
|
||||||
|
|
||||||
# Outputs do Playwright
|
# Outputs do Playwright
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: layout_variant aceita 'melissa'
|
||||||
|
-- ==========================================================================
|
||||||
|
-- O CHECK constraint user_settings_layout_variant_check restringia o valor
|
||||||
|
-- a ('classic', 'rail'). Com a chegada do Layout Melissa (Direção B do
|
||||||
|
-- redesign — wrapper estilo Win11 lockscreen), precisamos aceitar o valor
|
||||||
|
-- 'melissa' tambem.
|
||||||
|
--
|
||||||
|
-- Wire-up real do router (troca do AppLayout pelo MelissaLayout) ainda nao
|
||||||
|
-- foi feito (Fase 5 do roadmap Melissa) — mas a preferencia ja precisa
|
||||||
|
-- persistir desde agora pra UI do /profile funcionar.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.user_settings
|
||||||
|
DROP CONSTRAINT IF EXISTS user_settings_layout_variant_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.user_settings
|
||||||
|
ADD CONSTRAINT user_settings_layout_variant_check
|
||||||
|
CHECK (layout_variant = ANY (ARRAY['classic'::text, 'rail'::text, 'melissa'::text]));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_settings.layout_variant
|
||||||
|
IS 'classic (sidebar) | rail (mini rail + painel) | melissa (Win11 lockscreen, Beta)';
|
||||||
Vendored
+4
-4
@@ -105,7 +105,7 @@ export async function bootstrapUserSettings({
|
|||||||
const _lsV = (() => {
|
const _lsV = (() => {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem('layout_variant');
|
const v = localStorage.getItem('layout_variant');
|
||||||
return v === 'rail' || v === 'classic' ? v : null;
|
return v === 'rail' || v === 'classic' || v === 'melissa' ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -114,9 +114,9 @@ export async function bootstrapUserSettings({
|
|||||||
if (_lsV !== null) {
|
if (_lsV !== null) {
|
||||||
// localStorage já tem valor → aplica ele (garante coerência com layoutConfig)
|
// localStorage já tem valor → aplica ele (garante coerência com layoutConfig)
|
||||||
if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV);
|
if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV);
|
||||||
} else if (settings.layout_variant === 'rail') {
|
} else if (settings.layout_variant === 'rail' || settings.layout_variant === 'melissa') {
|
||||||
// localStorage vazio + banco tem 'rail' → aplica e grava no localStorage
|
// localStorage vazio + banco tem 'rail'/'melissa' → aplica e grava no localStorage
|
||||||
setVariant('rail');
|
setVariant(settings.layout_variant);
|
||||||
}
|
}
|
||||||
// localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica)
|
// localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica)
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,14 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
|
|||||||
const BREAKPOINT_XL = 1280;
|
const BREAKPOINT_XL = 1280;
|
||||||
|
|
||||||
// ── resolve variant salvo no localStorage ───────────────────
|
// ── resolve variant salvo no localStorage ───────────────────
|
||||||
|
// 'classic' = Sidebar lateral
|
||||||
|
// 'rail' = Mini rail + painel
|
||||||
|
// 'melissa' = Layout Melissa (Direção B). Hoje só persiste a preferência —
|
||||||
|
// o switch real do app vem na Fase 5 (router wire-up).
|
||||||
function _loadVariant() {
|
function _loadVariant() {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem('layout_variant');
|
const v = localStorage.getItem('layout_variant');
|
||||||
if (v === 'rail' || v === 'classic') return v;
|
if (v === 'rail' || v === 'classic' || v === 'melissa') return v;
|
||||||
} catch {}
|
} catch {}
|
||||||
return 'rail';
|
return 'rail';
|
||||||
}
|
}
|
||||||
@@ -199,7 +203,7 @@ export function useLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setVariant = (v, { fromUser = true } = {}) => {
|
const setVariant = (v, { fromUser = true } = {}) => {
|
||||||
if (v !== 'classic' && v !== 'rail') return;
|
if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return;
|
||||||
layoutConfig.variant = v;
|
layoutConfig.variant = v;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('layout_variant', v);
|
localStorage.setItem('layout_variant', v);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
|||||||
|
<script setup>
|
||||||
|
/*
|
||||||
|
* MelissaBusca
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Busca rápida glass-style. Adaptação do GlobalSearch da topbar
|
||||||
|
* (`src/components/search/GlobalSearch.vue`) pro layout Melissa.
|
||||||
|
*
|
||||||
|
* Diferenças vs. GlobalSearch:
|
||||||
|
* - Não chama Supabase — recebe pacientes/eventos via prop (preview)
|
||||||
|
* - Visual glass (white-on-glass) ao invés de surface-card
|
||||||
|
* - Sem loading state (busca client-side, instantâneo)
|
||||||
|
* - Emite ações pro parent decidir o que fazer
|
||||||
|
*
|
||||||
|
* Quando promover pra produção: trocar a busca por chamada à RPC
|
||||||
|
* `search_global` + manter a mesma estrutura de panel/items.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
pacientes: { type: Array, default: () => [] },
|
||||||
|
eventos: { type: Array, default: () => [] },
|
||||||
|
atalhos: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ id: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Sessões e compromissos', keywords: ['agenda', 'calendario', 'sessoes', 'hoje'] },
|
||||||
|
{ id: 'pacientes', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Cadastro e prontuários', keywords: ['pacientes', 'lista', 'cadastro'] },
|
||||||
|
{ id: 'conversas', label: 'WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Conversas em andamento', keywords: ['whatsapp', 'conversas', 'mensagens', 'chat'] },
|
||||||
|
{ id: 'financeiro', label: 'Financeiro', icon: 'pi pi-wallet', sublabel: 'Recebíveis e lançamentos', keywords: ['financeiro', 'pagamentos', 'cobrancas', 'dinheiro'] },
|
||||||
|
{ id: 'configuracoes', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências e equipe', keywords: ['configuracoes', 'ajustes', 'preferencias', 'settings'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['acao', 'paciente', 'evento']);
|
||||||
|
|
||||||
|
const rootEl = ref(null);
|
||||||
|
const inputEl = ref(null);
|
||||||
|
const query = ref('');
|
||||||
|
const showPanel = ref(false);
|
||||||
|
const activeIndex = ref(-1);
|
||||||
|
|
||||||
|
function normalize(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAtalhos = computed(() => {
|
||||||
|
const q = normalize(query.value);
|
||||||
|
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
|
||||||
|
return props.atalhos.filter((a) => {
|
||||||
|
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
|
||||||
|
return hay.includes(q);
|
||||||
|
}).slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredPacientes = computed(() => {
|
||||||
|
const q = normalize(query.value);
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
return props.pacientes
|
||||||
|
.filter((p) => normalize(p.nome).includes(q))
|
||||||
|
.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredEventos = computed(() => {
|
||||||
|
const q = normalize(query.value);
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
return props.eventos
|
||||||
|
.filter((e) => {
|
||||||
|
const hay = normalize(
|
||||||
|
(e.label || '') + ' ' + (e.pacienteNome || '') + ' ' + (e.descricao || '')
|
||||||
|
);
|
||||||
|
return hay.includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatList = computed(() => {
|
||||||
|
const out = [];
|
||||||
|
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
|
||||||
|
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
|
||||||
|
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAnyResult = computed(() => flatList.value.length > 0);
|
||||||
|
|
||||||
|
function findFlatIndex(group, idx) {
|
||||||
|
return flatList.value.findIndex((x) => x.group === group && x.idx === idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEntry(entry) {
|
||||||
|
if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
||||||
|
else if (entry.group === 'pacientes') emit('paciente', entry.item);
|
||||||
|
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
showPanel.value = false;
|
||||||
|
query.value = '';
|
||||||
|
activeIndex.value = -1;
|
||||||
|
inputEl.value?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
showPanel.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(e) {
|
||||||
|
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||||||
|
showPanel.value = false;
|
||||||
|
activeIndex.value = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (!showPanel.value) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||||
|
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectEntry(flatList.value[activeIndex.value]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
// Stop bubbling pra ESC do parent não fechar overlay aleatório
|
||||||
|
e.stopPropagation();
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalKeydown(e) {
|
||||||
|
// Ctrl+K / ⌘+K → foca input
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
showPanel.value = true;
|
||||||
|
inputEl.value?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
window.addEventListener('keydown', onGlobalKeydown);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
window.removeEventListener('keydown', onGlobalKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootEl" class="mb-search">
|
||||||
|
<div class="mb-field">
|
||||||
|
<i class="pi pi-search mb-field__icon" />
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar paciente, agenda, atalho…"
|
||||||
|
class="mb-field__input"
|
||||||
|
@focus="onFocus"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="mb-fade">
|
||||||
|
<div v-if="showPanel" class="mb-panel" role="listbox">
|
||||||
|
<div
|
||||||
|
v-if="query.trim().length >= 2 && !hasAnyResult"
|
||||||
|
class="mb-empty"
|
||||||
|
>
|
||||||
|
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Atalhos -->
|
||||||
|
<div v-if="filteredAtalhos.length" class="mb-group">
|
||||||
|
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
||||||
|
<button
|
||||||
|
v-for="(a, i) in filteredAtalhos"
|
||||||
|
:key="'a-' + a.id"
|
||||||
|
class="mb-item"
|
||||||
|
:class="{ 'is-active': findFlatIndex('atalhos', i) === activeIndex }"
|
||||||
|
@click="selectEntry({ group: 'atalhos', item: a })"
|
||||||
|
@mouseenter="activeIndex = findFlatIndex('atalhos', i)"
|
||||||
|
>
|
||||||
|
<span class="mb-item__icon"><i :class="a.icon" /></span>
|
||||||
|
<span class="mb-item__main">
|
||||||
|
<span class="mb-item__label">{{ a.label }}</span>
|
||||||
|
<span v-if="a.sublabel" class="mb-item__sub">{{ a.sublabel }}</span>
|
||||||
|
</span>
|
||||||
|
<i class="mb-item__go pi pi-arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pacientes -->
|
||||||
|
<div v-if="filteredPacientes.length" class="mb-group">
|
||||||
|
<div class="mb-group__title">Pacientes</div>
|
||||||
|
<button
|
||||||
|
v-for="(p, i) in filteredPacientes"
|
||||||
|
:key="'p-' + p.id"
|
||||||
|
class="mb-item"
|
||||||
|
:class="{ 'is-active': findFlatIndex('pacientes', i) === activeIndex }"
|
||||||
|
@click="selectEntry({ group: 'pacientes', item: p })"
|
||||||
|
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
|
||||||
|
>
|
||||||
|
<span class="mb-item__icon"><i class="pi pi-user" /></span>
|
||||||
|
<span class="mb-item__main">
|
||||||
|
<span class="mb-item__label">{{ p.nome }}</span>
|
||||||
|
<span class="mb-item__sub">Abrir prontuário</span>
|
||||||
|
</span>
|
||||||
|
<i class="mb-item__go pi pi-arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Eventos -->
|
||||||
|
<div v-if="filteredEventos.length" class="mb-group">
|
||||||
|
<div class="mb-group__title">Agenda de hoje</div>
|
||||||
|
<button
|
||||||
|
v-for="(e, i) in filteredEventos"
|
||||||
|
:key="'e-' + e.id"
|
||||||
|
class="mb-item"
|
||||||
|
:class="{ 'is-active': findFlatIndex('eventos', i) === activeIndex }"
|
||||||
|
@click="selectEntry({ group: 'eventos', item: e })"
|
||||||
|
@mouseenter="activeIndex = findFlatIndex('eventos', i)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mb-item__icon"
|
||||||
|
:style="{ backgroundColor: `${e.color}33`, color: e.color }"
|
||||||
|
>
|
||||||
|
<i class="pi pi-clock" />
|
||||||
|
</span>
|
||||||
|
<span class="mb-item__main">
|
||||||
|
<span class="mb-item__label">{{ e.label }}</span>
|
||||||
|
<span class="mb-item__sub">{{ fmtHora(e.startH) }} – {{ fmtHora(e.endH) }}</span>
|
||||||
|
</span>
|
||||||
|
<i class="mb-item__go pi pi-arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-search {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 44px;
|
||||||
|
transition: background-color 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
.mb-field:focus-within {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
border-color: var(--m-border-strong);
|
||||||
|
}
|
||||||
|
.mb-field__icon {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mb-field__input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mb-field__input::placeholder {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
}
|
||||||
|
.mb-field__kbd {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--m-border-strong) transparent;
|
||||||
|
}
|
||||||
|
.mb-panel::-webkit-scrollbar { width: 6px; }
|
||||||
|
.mb-panel::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--m-border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-empty {
|
||||||
|
padding: 18px 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-group + .mb-group {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--m-border);
|
||||||
|
}
|
||||||
|
.mb-group__title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--m-text-faint);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background-color 100ms ease;
|
||||||
|
}
|
||||||
|
.mb-item:hover,
|
||||||
|
.mb-item.is-active {
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
}
|
||||||
|
.mb-item__icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.mb-item__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.mb-item__label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: white;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mb-item__sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mb-item__go {
|
||||||
|
color: var(--m-text-faint);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-fade-enter-active,
|
||||||
|
.mb-fade-leave-active {
|
||||||
|
transition: opacity 140ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.mb-fade-enter-from,
|
||||||
|
.mb-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup>
|
||||||
|
/*
|
||||||
|
* MelissaCard
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Card glass do dashboard Melissa.
|
||||||
|
*
|
||||||
|
* Dois variants:
|
||||||
|
* - default: header (ícone+título+badge opcional) + slot de conteúdo
|
||||||
|
* + botão redondo "+" centralizado na borda inferior que
|
||||||
|
* emite `open` (parent decide o que fazer)
|
||||||
|
* - add: placeholder tracejado, meia largura, com "+" centralizado;
|
||||||
|
* emite `add` ao clicar
|
||||||
|
*
|
||||||
|
* Largura é fixa (não estica) — o layout flex no parent define o número
|
||||||
|
* de cards visíveis conforme o tamanho da tela.
|
||||||
|
*/
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator: (v) => ['default', 'add'].includes(v)
|
||||||
|
},
|
||||||
|
icon: { type: String, default: '' }, // ex.: 'pi pi-user'
|
||||||
|
iconColor: { type: String, default: '' }, // classe Tailwind ex.: 'text-emerald-300'
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
badge: { type: [String, Number, null], default: null },
|
||||||
|
badgeColor: { type: String, default: 'bg-red-500/80' }, // classe Tailwind
|
||||||
|
actionTitle: { type: String, default: 'Abrir' }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['open', 'add']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article v-if="variant === 'default'" class="mc-card">
|
||||||
|
<div class="mc-card__head">
|
||||||
|
<span v-if="icon" class="mc-card__icon">
|
||||||
|
<i :class="[icon, iconColor]" />
|
||||||
|
</span>
|
||||||
|
<span class="mc-card__title">{{ title }}</span>
|
||||||
|
<span v-if="badge !== null && badge !== ''" class="mc-card__badge" :class="badgeColor">
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-card__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<button class="mc-card__go" :title="actionTitle" @click="$emit('open')">
|
||||||
|
<i class="pi pi-plus text-xs" />
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<button v-else class="mc-card-add" :title="actionTitle" @click="$emit('add')">
|
||||||
|
<i class="pi pi-plus" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ─── Card padrão ──────────────────────────────────────────── */
|
||||||
|
.mc-card {
|
||||||
|
position: relative; /* ancora o botão "+" */
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 210px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
transition: background-color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.mc-card:hover {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--m-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.mc-card__icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
backdrop-filter: blur(16px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(160%);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mc-card__title {
|
||||||
|
/* deixa o título absorver o espaço entre ícone e badge */
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mc-card__badge {
|
||||||
|
/* margin-left: auto não precisa mais (title flex:1 empurra o badge) */
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-card__body {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botão "+" centralizado na borda inferior do card */
|
||||||
|
.mc-card__go {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(30, 30, 45, 0.85);
|
||||||
|
backdrop-filter: blur(20px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
color: white;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
transition: background-color 180ms ease, transform 180ms ease, border-color 180ms ease;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.mc-card__go:hover {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
border-color: var(--m-border-strong);
|
||||||
|
transform: translateX(-50%) scale(1.08);
|
||||||
|
}
|
||||||
|
.mc-card__go:active {
|
||||||
|
transform: translateX(-50%) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Card "Adicionar" (tracejado, meia largura) ──────────── */
|
||||||
|
/* Sem align-self aqui — o parent (cards-shell) decide se estica
|
||||||
|
(linha única) ou mantém intrínseco (wrap). */
|
||||||
|
.mc-card-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 130px; /* metade do card padrão */
|
||||||
|
min-height: 100px;
|
||||||
|
border: 1.5px dashed var(--m-border-strong);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
.mc-card-add:hover {
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border-color: var(--m-text-muted);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.mc-card-add:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
<script setup>
|
||||||
|
/*
|
||||||
|
* MelissaCronometro
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Cronômetro de sessão estilo "janela do Windows":
|
||||||
|
* - Dialog centralizado com select de paciente, display gigante e ações
|
||||||
|
* - Click fora minimiza (chip no canto superior esquerdo)
|
||||||
|
* - X/ESC fecha (destrói)
|
||||||
|
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
|
||||||
|
* - "+1 minuto" estende o tempo
|
||||||
|
* - Quando minimizado, o timer continua rodando em background
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* <MelissaCronometro
|
||||||
|
* ref="cronoRef"
|
||||||
|
* :pacientes="lista"
|
||||||
|
* default-paciente-id="p1"
|
||||||
|
* :duracao-minutos="50"
|
||||||
|
* @visible-change="cronoVisivel = $event"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* cronoRef.value.abrir() // abre / restaura
|
||||||
|
* cronoRef.value.fechar() // destrói
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { playToque } from './melissaToques';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'melissa.cronometro.v1';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
pacientes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
// Cada item: { id: string, nome: string }
|
||||||
|
},
|
||||||
|
defaultPacienteId: {
|
||||||
|
type: [String, Number, null],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
duracaoMinutos: {
|
||||||
|
type: Number,
|
||||||
|
default: 50
|
||||||
|
},
|
||||||
|
toqueTermino: {
|
||||||
|
type: String,
|
||||||
|
default: 'sino'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['visible-change', 'close', 'complete']);
|
||||||
|
|
||||||
|
// ── Estado interno ─────────────────────────────────────────────
|
||||||
|
const exists = ref(false);
|
||||||
|
const minimized = ref(false);
|
||||||
|
const running = ref(false);
|
||||||
|
const seconds = ref(props.duracaoMinutos * 60);
|
||||||
|
const pacienteId = ref(props.defaultPacienteId);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
// True só durante a transição de minimizar (dialog → chip).
|
||||||
|
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
|
||||||
|
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
|
||||||
|
const isMinimizing = ref(false);
|
||||||
|
|
||||||
|
// ── Computeds ──────────────────────────────────────────────────
|
||||||
|
const visible = computed(() => exists.value && !minimized.value);
|
||||||
|
|
||||||
|
const dialogTransitionName = computed(() => (isMinimizing.value ? 'minimize' : 'lift'));
|
||||||
|
|
||||||
|
const display = computed(() => {
|
||||||
|
const total = seconds.value;
|
||||||
|
const sign = total < 0 ? '-' : '';
|
||||||
|
const abs = Math.abs(total);
|
||||||
|
const m = Math.floor(abs / 60);
|
||||||
|
const s = abs % 60;
|
||||||
|
return `${sign}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const excedido = computed(() => seconds.value < 0);
|
||||||
|
|
||||||
|
const status = computed(() => {
|
||||||
|
if (running.value) return 'Em andamento';
|
||||||
|
if (seconds.value === props.duracaoMinutos * 60) return 'Pronto';
|
||||||
|
return 'Pausado';
|
||||||
|
});
|
||||||
|
|
||||||
|
const pacienteNome = computed(() => {
|
||||||
|
if (!pacienteId.value) return 'Atividade livre';
|
||||||
|
const p = props.pacientes.find((x) => x.id === pacienteId.value);
|
||||||
|
return p ? p.nome : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Watch: avisa parent quando dialog aparece/some ─────────────
|
||||||
|
watch(visible, (v) => emit('visible-change', v));
|
||||||
|
|
||||||
|
// ── Ações ──────────────────────────────────────────────────────
|
||||||
|
function abrir() {
|
||||||
|
if (exists.value) {
|
||||||
|
// Já existe — apenas restaura se tava minimizado (não cria outro)
|
||||||
|
if (minimized.value) minimized.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seconds.value = props.duracaoMinutos * 60;
|
||||||
|
pacienteId.value = props.defaultPacienteId;
|
||||||
|
running.value = false;
|
||||||
|
minimized.value = false;
|
||||||
|
exists.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (running.value) {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
running.value = false;
|
||||||
|
} else {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = setInterval(() => {
|
||||||
|
const wasPositive = seconds.value > 0;
|
||||||
|
seconds.value -= 1;
|
||||||
|
// Toca exatamente na transição positivo → zero/negativo (uma única vez)
|
||||||
|
if (wasPositive && seconds.value <= 0) {
|
||||||
|
playToque(props.toqueTermino);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
running.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimizar() {
|
||||||
|
isMinimizing.value = true;
|
||||||
|
minimized.value = true;
|
||||||
|
// Reset do flag depois da animação completar (340ms + buffer)
|
||||||
|
setTimeout(() => { isMinimizing.value = false; }, 380);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restaurar() {
|
||||||
|
minimized.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fechar() {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
running.value = false;
|
||||||
|
minimized.value = false;
|
||||||
|
exists.value = false;
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ajustarMinutos(delta) {
|
||||||
|
seconds.value += delta * 60;
|
||||||
|
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistência (localStorage) ────────────────────────────────
|
||||||
|
// Snapshot inclui timestamp pra calcular elapsed real no restore —
|
||||||
|
// caso o usuário recarregue/feche a aba enquanto o timer roda, ao
|
||||||
|
// voltar o seconds reflete o tempo real (a sessão acontece na sala,
|
||||||
|
// não na aba).
|
||||||
|
function saveState() {
|
||||||
|
if (!exists.value) {
|
||||||
|
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snap = {
|
||||||
|
pacienteId: pacienteId.value,
|
||||||
|
minimized: !!minimized.value,
|
||||||
|
running: !!running.value,
|
||||||
|
seconds: seconds.value,
|
||||||
|
savedAt: Date.now()
|
||||||
|
};
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
let raw;
|
||||||
|
try { raw = localStorage.getItem(STORAGE_KEY); } catch { return false; }
|
||||||
|
if (!raw) return false;
|
||||||
|
|
||||||
|
let snap;
|
||||||
|
try { snap = JSON.parse(raw); } catch { return false; }
|
||||||
|
if (!snap || typeof snap !== 'object') return false;
|
||||||
|
|
||||||
|
// Sanitização: cada campo do storage é input externo, não confiar
|
||||||
|
const validIds = new Set(props.pacientes.map((p) => p.id));
|
||||||
|
const pid = snap.pacienteId === null || validIds.has(snap.pacienteId)
|
||||||
|
? snap.pacienteId
|
||||||
|
: props.defaultPacienteId;
|
||||||
|
|
||||||
|
const savedSeconds = Number(snap.seconds);
|
||||||
|
if (!Number.isFinite(savedSeconds)) return false;
|
||||||
|
|
||||||
|
const savedAt = Number(snap.savedAt);
|
||||||
|
const wasRunning = !!snap.running;
|
||||||
|
|
||||||
|
// Se rodava, desconta tempo real desde o save (limite 24h pra evitar abuso de relógio)
|
||||||
|
let restoredSeconds = savedSeconds;
|
||||||
|
if (wasRunning && Number.isFinite(savedAt)) {
|
||||||
|
const elapsed = Math.max(0, Math.min(86400, Math.floor((Date.now() - savedAt) / 1000)));
|
||||||
|
restoredSeconds = savedSeconds - elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
pacienteId.value = pid;
|
||||||
|
minimized.value = !!snap.minimized;
|
||||||
|
seconds.value = restoredSeconds;
|
||||||
|
exists.value = true;
|
||||||
|
running.value = false; // toggle abaixo flipa pra true
|
||||||
|
|
||||||
|
if (wasRunning) {
|
||||||
|
// Retoma o interval. NÃO toca o toque retroativo — se o tempo
|
||||||
|
// já está negativo no restore, foi "perdido" durante o reload.
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch nas mudanças de estado discreto (não em seconds enquanto roda — savedAt+delta dá conta)
|
||||||
|
watch([exists, minimized, running, pacienteId], () => saveState());
|
||||||
|
|
||||||
|
// ── Mount / Cleanup ────────────────────────────────────────────
|
||||||
|
onMounted(() => {
|
||||||
|
loadState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Dialog centralizado.
|
||||||
|
Transition name dinâmico: 'minimize' quando user minimiza (anima
|
||||||
|
shrink + fly em direção ao dock no bottom-left), 'lift' default
|
||||||
|
pros outros casos (fechar, abrir). -->
|
||||||
|
<Transition :name="dialogTransitionName">
|
||||||
|
<div v-if="visible" class="mc-layer" @click.self="minimizar">
|
||||||
|
<div class="mc-panel">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Cronômetro</div>
|
||||||
|
<div class="text-white text-lg font-light mt-1">{{ status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
|
||||||
|
<i class="pi pi-window-minimize text-white/90 text-xs" />
|
||||||
|
</button>
|
||||||
|
<button class="mc-glass-btn" title="Fechar" @click="fechar">
|
||||||
|
<i class="pi pi-times text-white/90 text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select de paciente / atividade -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2 block">
|
||||||
|
Paciente / atividade
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select v-model="pacienteId" class="mc-select">
|
||||||
|
<option :value="null">— Atividade livre (sem paciente)</option>
|
||||||
|
<option v-for="p in pacientes" :key="p.id" :value="p.id">
|
||||||
|
{{ p.nome }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<i class="pi pi-chevron-down mc-select-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display gigante + steppers manuais (+5 / -5) -->
|
||||||
|
<div class="mb-7">
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<button
|
||||||
|
class="mc-step-btn"
|
||||||
|
title="Adicionar 5 minutos"
|
||||||
|
@click="ajustarMinutos(5)"
|
||||||
|
>
|
||||||
|
+5
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mc-step-btn"
|
||||||
|
title="Remover 5 minutos"
|
||||||
|
:disabled="excedido"
|
||||||
|
@click="ajustarMinutos(-5)"
|
||||||
|
>
|
||||||
|
−5
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mc-display" :class="{ 'is-excedido': excedido }">
|
||||||
|
{{ display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-2 text-center" :class="excedido ? 'text-red-400 font-medium' : 'text-white/50'">
|
||||||
|
{{ excedido ? 'tempo excedido' : 'tempo restante' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ações -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="mc-btn mc-btn--secondary flex-1" @click="ajustarMinutos(1)">
|
||||||
|
+1 minuto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mc-btn flex-1"
|
||||||
|
:class="running ? 'mc-btn--danger' : 'mc-btn--primary'"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<i :class="running ? 'pi pi-stop-circle' : 'pi pi-play'" class="text-xs" />
|
||||||
|
{{ running ? 'Parar' : 'Começar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Chip minimizado — teleporta pro .melissa-dock (taskbar Win11
|
||||||
|
no bottom). Vive ao lado do ψ (à direita dele, dentro do flex
|
||||||
|
do dock). Transition envolve o Teleport (pattern oficial Vue):
|
||||||
|
o Teleport some/aparece como unidade, sem deixar comment
|
||||||
|
placeholder no target compartilhado — o que evitaria o bug
|
||||||
|
"emitsOptions: null" causado por múltiplos Teleports + v-if
|
||||||
|
interno apontando pro mesmo target. -->
|
||||||
|
<Transition name="chip-pop">
|
||||||
|
<Teleport v-if="exists && minimized" to=".melissa-dock">
|
||||||
|
<button
|
||||||
|
class="mc-chip"
|
||||||
|
title="Restaurar cronômetro"
|
||||||
|
@click="restaurar"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="pi pi-stopwatch text-sm"
|
||||||
|
:class="running ? 'text-emerald-300 mc-chip-pulse' : 'text-white/70'"
|
||||||
|
/>
|
||||||
|
<span class="mc-chip-time" :class="{ 'text-red-300': excedido }">
|
||||||
|
{{ display }}
|
||||||
|
</span>
|
||||||
|
<span class="mc-chip-name">{{ pacienteNome }}</span>
|
||||||
|
</button>
|
||||||
|
</Teleport>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ─── Dialog ───────────────────────────────────────────────── */
|
||||||
|
.mc-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.mc-panel {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
backdrop-filter: blur(32px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-glass-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease;
|
||||||
|
}
|
||||||
|
.mc-glass-btn:hover {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Select customizado ───────────────────────────────────── */
|
||||||
|
.mc-select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
color: var(--m-text);
|
||||||
|
padding: 11px 38px 11px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 140ms ease, background-color 140ms ease;
|
||||||
|
}
|
||||||
|
.mc-select:hover {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
}
|
||||||
|
.mc-select:focus {
|
||||||
|
border-color: var(--m-border-strong);
|
||||||
|
}
|
||||||
|
.mc-select option {
|
||||||
|
/* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */
|
||||||
|
background: var(--p-content-background);
|
||||||
|
color: var(--m-text);
|
||||||
|
}
|
||||||
|
.mc-select-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Display gigante ──────────────────────────────────────── */
|
||||||
|
.mc-display {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 200;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--m-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: color 200ms ease;
|
||||||
|
}
|
||||||
|
.mc-display.is-excedido {
|
||||||
|
color: #ef4444;
|
||||||
|
text-shadow: 0 4px 24px rgba(239, 68, 68, 0.45);
|
||||||
|
animation: mc-excedido-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes mc-excedido-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Steppers manuais (+5 / -5) ───────────────────────────── */
|
||||||
|
.mc-step-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
color: var(--m-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, border-color 140ms ease, transform 100ms ease;
|
||||||
|
}
|
||||||
|
.mc-step-btn:hover {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
border-color: var(--m-border-strong);
|
||||||
|
}
|
||||||
|
.mc-step-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.mc-step-btn:disabled {
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border-color: var(--m-border);
|
||||||
|
color: var(--m-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Botões de ação ───────────────────────────────────────── */
|
||||||
|
.mc-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
.mc-btn--secondary {
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
color: var(--m-text);
|
||||||
|
}
|
||||||
|
.mc-btn--secondary:hover {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
border-color: var(--m-border-strong);
|
||||||
|
}
|
||||||
|
.mc-btn--primary {
|
||||||
|
background: var(--m-accent);
|
||||||
|
border: 1px solid var(--m-accent);
|
||||||
|
color: var(--p-primary-contrast-color, white);
|
||||||
|
}
|
||||||
|
.mc-btn--primary:hover {
|
||||||
|
background: color-mix(in srgb, var(--m-accent) 80%, white);
|
||||||
|
}
|
||||||
|
.mc-btn--danger {
|
||||||
|
background: rgba(239, 68, 68, 0.55);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.7);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.mc-btn--danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Chip minimizado (teleportado pro .melissa-dock) ──────
|
||||||
|
Vive como filho flex do dock — não usa position fixed. Dock
|
||||||
|
posiciona ele à esquerda (depois do ψ, via padding-left). */
|
||||||
|
.mc-chip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px 8px 12px;
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
backdrop-filter: blur(24px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||||
|
border: 1px solid var(--m-border-strong);
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: var(--m-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease, transform 200ms ease, border-color 200ms ease;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
.mc-chip:hover {
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--m-border-strong);
|
||||||
|
}
|
||||||
|
.mc-chip-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.mc-chip-name {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: 6px;
|
||||||
|
border-left: 1px solid var(--m-border-strong);
|
||||||
|
}
|
||||||
|
.mc-chip-pulse {
|
||||||
|
animation: mc-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes mc-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Transições ───────────────────────────────────────────── */
|
||||||
|
.lift-enter-active,
|
||||||
|
.lift-leave-active {
|
||||||
|
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||||
|
}
|
||||||
|
.lift-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(12px);
|
||||||
|
}
|
||||||
|
.lift-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.98) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-pop-enter-active,
|
||||||
|
.chip-pop-leave-active {
|
||||||
|
transition: opacity 200ms ease, transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||||
|
}
|
||||||
|
.chip-pop-enter-from,
|
||||||
|
.chip-pop-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85) translateY(-8px);
|
||||||
|
}
|
||||||
|
/* Chip aparecendo após minimize: leve atraso pra deixar o dialog
|
||||||
|
"voar" antes de aparecer no dock (sensação macOS) */
|
||||||
|
.chip-pop-enter-active {
|
||||||
|
transition-delay: 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Animação "minimize" (macOS-style): dialog encolhe + voa pro
|
||||||
|
bottom-left em direção ao chip que vai aparecer no dock. Aplicada
|
||||||
|
só durante minimizar() — fechar normal usa lift. ────────────── */
|
||||||
|
.minimize-enter-active,
|
||||||
|
.minimize-leave-active {
|
||||||
|
transition: opacity 280ms ease, transform 340ms cubic-bezier(0.5, 0, 0.6, 1);
|
||||||
|
/* origem ~= posição do chip no dock (left:96px após ψ, bottom centro
|
||||||
|
da faixa de 76px = ~38px). Faz o scale "encolher em direção" lá. */
|
||||||
|
transform-origin: 96px calc(100% - 38px);
|
||||||
|
}
|
||||||
|
.minimize-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.05);
|
||||||
|
}
|
||||||
|
.minimize-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Light mode tweaks ────────────────────────────────────
|
||||||
|
Texto já usa var(--m-text) (flipa automático), mas alguns
|
||||||
|
shadows escuros agressivos precisam ser suavizados. */
|
||||||
|
html:not(.app-dark) .mc-display {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
html:not(.app-dark) .mc-chip {
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
html:not(.app-dark) .mc-panel {
|
||||||
|
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
* useMelissaEventos — composables que carregam eventos reais da agenda
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Pattern espelhado de `src/features/agenda/pages/AgendaTerapeutaPage.vue`
|
||||||
|
* (loadMonthSearchRows ~ linha 539).
|
||||||
|
*
|
||||||
|
* Exporta dois composables:
|
||||||
|
* - useMelissaEventosSemana(refDateRef) — semana de refDate (ref<Date>)
|
||||||
|
* - useMelissaEventosHoje() — apenas o dia atual
|
||||||
|
*
|
||||||
|
* Forma normalizada do evento:
|
||||||
|
* {
|
||||||
|
* id, tipo, status, titulo,
|
||||||
|
* pacienteNome, modalidade, descricao,
|
||||||
|
* color, label,
|
||||||
|
* inicio_em, fim_em,
|
||||||
|
* startH, endH, // decimais (9.5 = 09:30) — usado pelo layout
|
||||||
|
* dateKey // 'YYYY-MM-DD' pra agrupar por dia
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Sem auth/tenant: retorna [] silencioso (UI segue funcional).
|
||||||
|
*
|
||||||
|
* NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar
|
||||||
|
* o preview. Adicionar quando promover Melissa pra produção.
|
||||||
|
*/
|
||||||
|
import { ref, watch, onMounted, computed } from 'vue';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
|
||||||
|
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
|
||||||
|
function pickColor(tipo, status) {
|
||||||
|
const s = String(status || '').toLowerCase();
|
||||||
|
if (s === 'realizado' || s === 'realizada') return '#10b981';
|
||||||
|
if (s === 'faltou') return '#ef4444';
|
||||||
|
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
|
||||||
|
|
||||||
|
const t = String(tipo || '').toLowerCase();
|
||||||
|
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
|
||||||
|
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
|
||||||
|
if (t === 'bloqueio') return '#64748b';
|
||||||
|
return '#6366f1'; // sessao default
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoToDecimalHour(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.getHours() + d.getMinutes() / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEvent(r) {
|
||||||
|
const pacNome = r.patients?.nome_completo || '';
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
tipo: r.tipo || 'sessao',
|
||||||
|
status: r.status || '',
|
||||||
|
titulo: r.titulo || '',
|
||||||
|
pacienteNome: pacNome,
|
||||||
|
modalidade: r.modalidade || '',
|
||||||
|
descricao: r.observacoes || '',
|
||||||
|
color: pickColor(r.tipo, r.status),
|
||||||
|
label: pacNome || r.titulo || '—',
|
||||||
|
inicio_em: r.inicio_em,
|
||||||
|
fim_em: r.fim_em,
|
||||||
|
startH: isoToDecimalHour(r.inicio_em),
|
||||||
|
endH: isoToDecimalHour(r.fim_em),
|
||||||
|
dateKey: String(r.inicio_em || '').slice(0, 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper interno: garante uid + tenant + faz a query ──
|
||||||
|
async function _fetchRange(start, end) {
|
||||||
|
const tenantStore = useTenantStore();
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
const userId = userData?.user?.id || null;
|
||||||
|
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||||
|
await tenantStore.ensureLoaded();
|
||||||
|
}
|
||||||
|
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||||
|
|
||||||
|
if (!userId || !tid) return [];
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||||
|
.eq('owner_id', userId)
|
||||||
|
.is('mirror_of_event_id', null)
|
||||||
|
.gte('inicio_em', start.toISOString())
|
||||||
|
.lt('inicio_em', end.toISOString())
|
||||||
|
.order('inicio_em', { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map(normalizeEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Range helpers ──────────────────────────────────────────────
|
||||||
|
function rangeSemana(refDate) {
|
||||||
|
const ref = new Date(refDate);
|
||||||
|
const dow = ref.getDay(); // 0=dom, 1=seg
|
||||||
|
const diff = dow === 0 ? -6 : 1 - dow;
|
||||||
|
const segunda = new Date(ref);
|
||||||
|
segunda.setDate(ref.getDate() + diff);
|
||||||
|
segunda.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// domingo final → usa dia seguinte 00:00 com `lt` pra incluir tudo até 23:59:59.999
|
||||||
|
const apósDomingo = new Date(segunda);
|
||||||
|
apósDomingo.setDate(segunda.getDate() + 7);
|
||||||
|
return { start: segunda, end: apósDomingo };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeHoje() {
|
||||||
|
const inicio = new Date();
|
||||||
|
inicio.setHours(0, 0, 0, 0);
|
||||||
|
const fim = new Date(inicio);
|
||||||
|
fim.setDate(inicio.getDate() + 1); // amanhã 00:00
|
||||||
|
return { start: inicio, end: fim };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMPOSABLE 1: semana visível (MelissaAgenda) ──────────────
|
||||||
|
export function useMelissaEventosSemana(refDateRef) {
|
||||||
|
const eventos = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const { start, end } = rangeSemana(refDateRef.value);
|
||||||
|
eventos.value = await _fetchRange(start, end);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || 'Erro ao carregar agenda';
|
||||||
|
eventos.value = [];
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaEventosSemana]', e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetch);
|
||||||
|
watch(refDateRef, fetch);
|
||||||
|
|
||||||
|
// Helper computado: agrupa por dateKey ('YYYY-MM-DD')
|
||||||
|
const eventosPorDia = computed(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const ev of eventos.value) {
|
||||||
|
(map[ev.dateKey] ||= []).push(ev);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { eventos, eventosPorDia, loading, error, refetch: fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMPOSABLE 3: range arbitrário (FullCalendar passa via datesSet) ──
|
||||||
|
// Usado pelo MelissaAgenda — refetcha sempre que start/end mudam (mudança
|
||||||
|
// de view, navegação prev/next/today). Cobre Day/Week/Month/List sem custo.
|
||||||
|
export function useMelissaEventosRange(startRef, endRef) {
|
||||||
|
const eventos = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
const s = startRef.value;
|
||||||
|
const e = endRef.value;
|
||||||
|
if (!s || !e) { eventos.value = []; return; }
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
eventos.value = await _fetchRange(new Date(s), new Date(e));
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err?.message || 'Erro ao carregar agenda';
|
||||||
|
eventos.value = [];
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaEventosRange]', err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetch);
|
||||||
|
watch([startRef, endRef], fetch);
|
||||||
|
|
||||||
|
return { eventos, loading, error, refetch: fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
|
||||||
|
export function useMelissaEventosHoje() {
|
||||||
|
const eventos = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const { start, end } = rangeHoje();
|
||||||
|
eventos.value = await _fetchRange(start, end);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || 'Erro ao carregar agenda';
|
||||||
|
eventos.value = [];
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaEventosHoje]', e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetch);
|
||||||
|
|
||||||
|
return { eventos, loading, error, refetch: fetch };
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* useMelissaPacientes — composable que carrega pacientes ativos do terapeuta
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Pattern espelhado de `src/features/patients/PatientsListPage.vue`:
|
||||||
|
* - withOwnerFilter (apenas os pacientes do owner_id = uid do user logado)
|
||||||
|
* - withTenantFilter (defesa em profundidade — RLS cobre, mas blindamos no client)
|
||||||
|
* - normalizeStatus client-side (DB pode ter 'ativo'/'active'/null/etc.)
|
||||||
|
*
|
||||||
|
* Sem auth (sem uid ou tenant), retorna vazio sem erro — uso em /preview/melissa
|
||||||
|
* permite a página renderizar mesmo sem session.
|
||||||
|
*
|
||||||
|
* Forma normalizada do paciente:
|
||||||
|
* { id, nome, email, telefone, avatar_url, status, last_attended_at, created_at }
|
||||||
|
*
|
||||||
|
* Quando precisar fora do Melissa: promover pra src/composables/.
|
||||||
|
*/
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
|
||||||
|
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
|
||||||
|
function normalizeStatus(s) {
|
||||||
|
const v = String(s || '').toLowerCase().trim();
|
||||||
|
if (!v) return 'Ativo';
|
||||||
|
if (v === 'active' || v === 'ativo') return 'Ativo';
|
||||||
|
if (v === 'inactive' || v === 'inativo') return 'Inativo';
|
||||||
|
return v.charAt(0).toUpperCase() + v.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMelissaPacientes() {
|
||||||
|
const tenantStore = useTenantStore();
|
||||||
|
|
||||||
|
const pacientes = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const uid = ref(null);
|
||||||
|
|
||||||
|
async function ensureUid() {
|
||||||
|
if (uid.value) return uid.value;
|
||||||
|
const { data, error: err } = await supabase.auth.getUser();
|
||||||
|
if (err) return null;
|
||||||
|
uid.value = data?.user?.id || null;
|
||||||
|
return uid.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPacientes() {
|
||||||
|
const userId = await ensureUid();
|
||||||
|
|
||||||
|
// Garante que o tenantStore foi hidratado (preview misc não passa por
|
||||||
|
// guard de auth, então o store pode estar vazio mesmo com user logado)
|
||||||
|
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||||
|
await tenantStore.ensureLoaded();
|
||||||
|
}
|
||||||
|
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||||
|
|
||||||
|
if (!userId || !tid) {
|
||||||
|
pacientes.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Não filtra status no SQL — DB tem valores inconsistentes
|
||||||
|
// ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client.
|
||||||
|
const { data, error: err } = await supabase
|
||||||
|
.from('patients')
|
||||||
|
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at')
|
||||||
|
.eq('owner_id', userId)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.order('nome_completo', { ascending: true })
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
|
if (err) throw err;
|
||||||
|
|
||||||
|
const todos = (data || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
nome: r.nome_completo || '',
|
||||||
|
email: r.email_principal || '',
|
||||||
|
telefone: r.telefone || '',
|
||||||
|
avatar_url: r.avatar_url || null,
|
||||||
|
status: normalizeStatus(r.status),
|
||||||
|
last_attended_at: r.last_attended_at || null,
|
||||||
|
created_at: r.created_at || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
pacientes.value = todos.filter((p) => p.status === 'Ativo');
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || 'Erro ao carregar pacientes';
|
||||||
|
pacientes.value = [];
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaPacientes]', e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchPacientes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pacientes,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchPacientes
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* melissaToques — geração de toques de término via Web Audio API
|
||||||
|
* --------------------------------------------------------------
|
||||||
|
* Não usa arquivos de áudio externos. Tudo gerado em runtime com
|
||||||
|
* osciladores. Mantém self-hosted, leve e sem build de assets.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* import { TOQUES, playToque } from './melissaToques';
|
||||||
|
* playToque('sino');
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TOQUES = [
|
||||||
|
{ id: 'sino', label: 'Sino' },
|
||||||
|
{ id: 'acorde', label: 'Acorde' },
|
||||||
|
{ id: 'tic-tac', label: 'Tic-tac' },
|
||||||
|
{ id: 'suave', label: 'Suave' },
|
||||||
|
{ id: 'nenhum', label: 'Nenhum (silencioso)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function getCtx() {
|
||||||
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
return Ctx ? new Ctx() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sino: clássico ding com decaimento longo
|
||||||
|
function playSino(ctx) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = 880; // A5
|
||||||
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + 0.01);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.6);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 1.7);
|
||||||
|
setTimeout(() => ctx.close(), 1900);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acorde: C maior arpejado (C5 E5 G5)
|
||||||
|
function playAcorde(ctx) {
|
||||||
|
const freqs = [523.25, 659.25, 783.99];
|
||||||
|
freqs.forEach((freq, i) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
const start = ctx.currentTime + i * 0.07;
|
||||||
|
gain.gain.setValueAtTime(0.0001, start);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, start + 1.5);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + 1.6);
|
||||||
|
});
|
||||||
|
setTimeout(() => ctx.close(), 2100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tic-tac: dois cliques curtos e secos
|
||||||
|
function playTicTac(ctx) {
|
||||||
|
[0, 0.18].forEach((delay) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.value = 1500;
|
||||||
|
const start = ctx.currentTime + delay;
|
||||||
|
gain.gain.setValueAtTime(0.0001, start);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.12, start + 0.005);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.06);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + 0.07);
|
||||||
|
});
|
||||||
|
setTimeout(() => ctx.close(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suave: fade-in/fade-out lento, quase respiração
|
||||||
|
function playSuave(ctx) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = 660; // E5
|
||||||
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.5);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 2.5);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 2.6);
|
||||||
|
setTimeout(() => ctx.close(), 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYERS = {
|
||||||
|
sino: playSino,
|
||||||
|
acorde: playAcorde,
|
||||||
|
'tic-tac': playTicTac,
|
||||||
|
suave: playSuave,
|
||||||
|
nenhum: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function playToque(id) {
|
||||||
|
if (id === 'nenhum') return;
|
||||||
|
const fn = PLAYERS[id] || PLAYERS.sino;
|
||||||
|
try {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
// Web Audio em alguns browsers começa suspended até primeira interação
|
||||||
|
if (ctx.state === 'suspended') ctx.resume?.();
|
||||||
|
fn(ctx);
|
||||||
|
} catch {
|
||||||
|
// Falha silenciosa: não há nada útil a fazer aqui
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,14 @@ export default {
|
|||||||
component: () => import('@/views/pages/Landing.vue')
|
component: () => import('@/views/pages/Landing.vue')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sandbox do layout Melissa (Direção B — lockscreen-style)
|
||||||
|
// Standalone, sem auth, sem AppLayout. Promovido de /preview/dashboard-win11.
|
||||||
|
{
|
||||||
|
path: 'preview/melissa',
|
||||||
|
name: 'PreviewMelissa',
|
||||||
|
component: () => import('@/layout/melissa/MelissaLayout.vue')
|
||||||
|
},
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
{
|
{
|
||||||
path: 'pages/notfound',
|
path: 'pages/notfound',
|
||||||
|
|||||||
@@ -1366,7 +1366,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="h-px bg-[var(--surface-border)] my-5" />
|
<div class="h-px bg-[var(--surface-border)] my-5" />
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<!-- Layout 1: Clássico -->
|
<!-- Layout 1: Clássico -->
|
||||||
<button
|
<button
|
||||||
class="lv-card"
|
class="lv-card"
|
||||||
@@ -1423,12 +1423,44 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Layout 3: Melissa (Direção B) — Em construção -->
|
||||||
|
<button
|
||||||
|
class="lv-card lv-card--wip"
|
||||||
|
:class="{ 'lv-card--active': layoutConfig.variant === 'melissa' }"
|
||||||
|
@click="
|
||||||
|
setVariant('melissa');
|
||||||
|
markDirty();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="lv-card__badge">Em construção</span>
|
||||||
|
<div class="lv-card__preview lv-card__preview--melissa">
|
||||||
|
<div class="lv-pm__clock">12<span class="lv-pm__sep">:</span>30</div>
|
||||||
|
<div class="lv-pm__cards">
|
||||||
|
<div class="lv-pm__card" />
|
||||||
|
<div class="lv-pm__card" />
|
||||||
|
<div class="lv-pm__card" />
|
||||||
|
</div>
|
||||||
|
<div class="lv-pm__psi">ψ</div>
|
||||||
|
</div>
|
||||||
|
<div class="lv-card__foot">
|
||||||
|
<div class="lv-card__radio">
|
||||||
|
<div v-if="layoutConfig.variant === 'melissa'" class="lv-card__dot" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lv-card__name">Melissa</div>
|
||||||
|
<div class="lv-card__sub">Tela cheia estilo Win11 lockscreen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
|
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
|
||||||
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
|
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
|
||||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||||
O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop.
|
<strong>Rail:</strong> ícones no canto esquerdo + painel expansível. Disponível apenas no desktop.<br />
|
||||||
|
<strong>Melissa</strong> <span class="text-[0.85rem] uppercase tracking-wider font-semibold" style="color: var(--primary-color)">— em construção:</span> layout fullscreen com resumo do dia, busca rápida e cronômetro de sessão. Selecionar aqui salva sua preferência, mas a navegação completa ainda não está integrada. Acesse o preview em
|
||||||
|
<a href="/preview/melissa" target="_blank" rel="noopener" class="underline hover:text-[var(--text-color)]">/preview/melissa</a>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1702,6 +1734,90 @@ onBeforeUnmount(() => {
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Card Melissa (Direção B) — preview Win11 lockscreen ──── */
|
||||||
|
.lv-card--wip {
|
||||||
|
/* Listras suaves no fundo do card pra reforçar visualmente "em obras",
|
||||||
|
sem prejudicar o preview no topo. */
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 0,
|
||||||
|
transparent 12px,
|
||||||
|
color-mix(in srgb, var(--primary-color) 4%, transparent) 12px,
|
||||||
|
color-mix(in srgb, var(--primary-color) 4%, transparent) 14px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.lv-card__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.lv-card__preview--melissa {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 55%),
|
||||||
|
radial-gradient(circle at 25% 75%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, var(--surface-900, #0f172a) 0%, color-mix(in srgb, var(--primary-color) 50%, var(--surface-900, #0f172a)) 50%, var(--surface-900, #0f172a) 100%);
|
||||||
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.lv-pm__clock {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 200;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.lv-pm__sep {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.lv-pm__cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.lv-pm__card {
|
||||||
|
width: 14px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.lv-pm__psi {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 8px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-family: 'Instrument Serif', Georgia, serif;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-style: italic;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Animation ─────────────────────────────────────────── */
|
/* ─── Animation ─────────────────────────────────────────── */
|
||||||
@keyframes prof-fadeUp {
|
@keyframes prof-fadeUp {
|
||||||
from {
|
from {
|
||||||
|
|||||||
Reference in New Issue
Block a user