Melissa polish + Prontuario Visao Geral + agenda historico

Sprints B (05-03) e C (05-04) acumulados:

- NotificationDrawer/Item redesign (visual mais limpo, ações inline)
- Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore)
- MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico
  card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado
- useFeriados: cache opt-in pra evitar fetch redundante de feriados
- PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish
- AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes
  de paridade com Melissa
- DocumentsListPage: pequenos ajustes
- DB migration 20260504000001: fix do trigger pra status 'excluido' nas
  cancel_notifications

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-06 09:11:55 -03:00
parent 86311ef305
commit 957e912a7f
19 changed files with 5203 additions and 285 deletions
@@ -0,0 +1,134 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/layout/melissa/composables/useMelissaDockPins.js
| Data: 2026-05-04
|
| Pins dinâmicos do dock Melissa — modelo híbrido:
|
| - PINNED (manual, max 4): user fixa via menu de contexto, persiste
| entre sessões em localStorage. Sempre visíveis, ordenados por ordem
| de fixação.
| - RECENT (MRU automático, max 3): toda vez que o user abre uma seção
| que NÃO é built-in (agenda/conversas) e NÃO tá pinned, vira o pin
| temporário mais recente, empurrando os mais antigos pra fora.
|
| Persistência: localStorage com chave `melissa.dock.pins.v1`. Salva só
| slugs de seção (string), nada de dado clínico — LGPD-safe. Singleton via
| módulo (estado fora da função) pra todas as instâncias compartilharem.
|
| Builtin (não-pinnável, não-recente): agenda, conversas — esses já têm
| pin permanente próprio no template (.dock-pin com hardcode).
|--------------------------------------------------------------------------
*/
import { ref, watch } from 'vue';
const STORAGE_KEY = 'melissa.dock.pins.v1';
const MAX_PINNED = 4;
const MAX_RECENT = 3;
const BUILTIN_SLUGS = new Set(['agenda', 'conversas']);
// Estado singleton compartilhado entre todas as instâncias.
const pinned = ref([]);
const recent = ref([]);
let _hydrated = false;
function _hydrate() {
if (_hydrated) return;
_hydrated = true;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.pinned)) {
pinned.value = parsed.pinned.filter((s) => typeof s === 'string').slice(0, MAX_PINNED);
}
if (Array.isArray(parsed?.recent)) {
recent.value = parsed.recent.filter((s) => typeof s === 'string').slice(0, MAX_RECENT);
}
} catch { /* localStorage corrompido — ignora silenciosamente */ }
}
function _persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
pinned: pinned.value,
recent: recent.value
}));
} catch { /* quota excedida ou storage desabilitado — ok, em memória */ }
}
let _persistWatcherActive = false;
function _ensurePersistWatcher() {
if (_persistWatcherActive) return;
_persistWatcherActive = true;
watch([pinned, recent], _persist, { deep: true });
}
export function useMelissaDockPins() {
_hydrate();
_ensurePersistWatcher();
function isBuiltin(slug) {
return BUILTIN_SLUGS.has(slug);
}
function isPinned(slug) {
return pinned.value.includes(slug);
}
function isRecent(slug) {
return recent.value.includes(slug);
}
// Chamado quando o user abre uma seção. Builtins e já-pinned não viram
// recent (não duplica). Mais recente entra no topo, expulsa o mais
// antigo se passar do limite.
function pushRecent(slug) {
if (!slug || isBuiltin(slug) || isPinned(slug)) return;
recent.value = [slug, ...recent.value.filter((s) => s !== slug)].slice(0, MAX_RECENT);
}
// Move um slug de "recent" pra "pinned" (ou cria pinned direto).
// Retorna { ok, reason } — reason='full' quando já tem 4 pinned.
function pin(slug) {
if (!slug || isBuiltin(slug)) return { ok: false, reason: 'builtin' };
if (isPinned(slug)) return { ok: true, reason: 'already' };
if (pinned.value.length >= MAX_PINNED) return { ok: false, reason: 'full' };
recent.value = recent.value.filter((s) => s !== slug);
pinned.value = [...pinned.value, slug];
return { ok: true };
}
// Tira de "pinned" — não volta automaticamente pra recent (o user
// explicitamente desafixou). Próxima abertura da seção vai pra recent
// pelo fluxo normal de pushRecent.
function unpin(slug) {
pinned.value = pinned.value.filter((s) => s !== slug);
}
// Remove completamente (de ambas as listas). Usado pelo "Remover" do menu.
function remove(slug) {
pinned.value = pinned.value.filter((s) => s !== slug);
recent.value = recent.value.filter((s) => s !== slug);
}
function clearAll() {
pinned.value = [];
recent.value = [];
}
return {
pinned,
recent,
isBuiltin,
isPinned,
isRecent,
pushRecent,
pin,
unpin,
remove,
clearAll,
MAX_PINNED,
MAX_RECENT
};
}