Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark

Sprints 04-29 + 04-30 acumuladas.

- MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/
  Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed.
- MelissaEmbed: wrapper generico que injeta layout-variant=melissa
  e remove cromos pra reaproveitar Pages tradicionais.
- 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes,
  Conversas, Embed, Grupos, Medicos, Recorrencias, Tags.
- Dialog blueprint atualizado: bg-gray-100 (hardcoded light) ->
  bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em
  9 arquivos. Anti-pattern documentado.
- PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name),
  toggle vertical/abas com persist localStorage, sticky margin-top.
- Surface picker no popover do MelissaLayout (8 swatches).
- useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos.
- Migration: status agenda remarcado/confirmado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-04 11:41:19 -03:00
parent 269c380d9c
commit 86311ef305
52 changed files with 16214 additions and 1027 deletions
@@ -63,7 +63,9 @@ function normalizeEvent(r) {
fim_em: r.fim_em,
startH: isoToDecimalHour(r.inicio_em),
endH: isoToDecimalHour(r.fim_em),
dateKey: String(r.inicio_em || '').slice(0, 10)
dateKey: String(r.inicio_em || '').slice(0, 10),
price: r.price != null ? Number(r.price) : 0,
billed: !!r.billed
};
}
@@ -81,7 +83,7 @@ async function _fetchRange(start, end) {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.eq('owner_id', userId)
.is('mirror_of_event_id', null)
.gte('inicio_em', start.toISOString())
@@ -185,6 +187,137 @@ export function useMelissaEventosRange(startRef, endRef) {
return { eventos, loading, error, refetch: fetch };
}
// ── Busca server-side: por nome de paciente ou título do evento ──
// Usado pela busca da toolbar do MelissaAgenda. Procura sem range temporal
// (acha sessões fora do que está visível no FC). Limite de 20 resultados,
// ordenados por inicio_em DESC (mais recente primeiro).
//
// Acento-insensitive: troca cada vogal/c do termo por um character class
// que casa com todas as variantes ("andre" vira "[aáàâã]ndr[eéèê]") e usa
// `imatch` (operador POSIX `~*` do Postgres — case-insensitive regex).
// Estratégia evita depender da extensão `unaccent` no DB.
function _buildAccentInsensitivePattern(term) {
// Escapa regex specials primeiro pra não quebrar o pattern.
const escaped = String(term || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const map = {
a: '[aáàâãAÁÀÂÃ]', e: '[eéèêEÉÈÊ]', i: '[iíìîIÍÌÎ]',
o: '[oóòôõOÓÒÔÕ]', u: '[uúùûUÚÙÛ]', c: '[cçCÇ]',
A: '[aáàâãAÁÀÂÃ]', E: '[eéèêEÉÈÊ]', I: '[iíìîIÍÌÎ]',
O: '[oóòôõOÓÒÔÕ]', U: '[uúùûUÚÙÛ]', C: '[cçCÇ]'
};
return escaped.split('').map((ch) => map[ch] || ch).join('');
}
export async function searchEventosByText(termo) {
const term = String(termo || '').trim();
if (term.length < 2) return [];
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 SELECT = 'id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)';
const pattern = _buildAccentInsensitivePattern(term);
try {
const [byPatient, byTitle] = await Promise.all([
supabase
.from('agenda_eventos')
.select(SELECT.replace('patients!agenda_eventos_patient_id_fkey', 'patients!inner!agenda_eventos_patient_id_fkey'))
.eq('owner_id', userId)
.is('mirror_of_event_id', null)
.filter('patients.nome_completo', 'imatch', pattern)
.order('inicio_em', { ascending: false })
.limit(20),
supabase
.from('agenda_eventos')
.select(SELECT)
.eq('owner_id', userId)
.is('mirror_of_event_id', null)
.filter('titulo', 'imatch', pattern)
.order('inicio_em', { ascending: false })
.limit(20)
]);
if (byPatient.error) throw byPatient.error;
if (byTitle.error) throw byTitle.error;
const merged = [...(byPatient.data || []), ...(byTitle.data || [])];
const seen = new Set();
const unique = [];
for (const r of merged) {
if (seen.has(r.id)) continue;
seen.add(r.id);
unique.push(r);
}
unique.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
return unique.slice(0, 20).map(normalizeEvent);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[searchEventosByText]', e);
return [];
}
}
// ── COMPOSABLE 4: todas as sessões de um paciente (sem range) ──
// Usado pelo banner "Ver todas" da MelissaAgenda quando o usuário
// quer escapar do range visível e ver o histórico completo do
// paciente selecionado. Diferente do useMelissaEventosRange, aqui
// filtramos por patient_id e ignoramos qualquer range temporal.
//
// Retorna eventos ordenados por inicio_em DESC (mais recente primeiro)
// — coerente com listas de "histórico" no resto do sistema.
export function useMelissaTodasSessoesPaciente() {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch(patientId) {
if (!patientId) { eventos.value = []; return; }
loading.value = true;
error.value = null;
try {
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) { eventos.value = []; return; }
const { data, error: err } = await supabase
.from('agenda_eventos')
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.eq('owner_id', userId)
.eq('patient_id', patientId)
.is('mirror_of_event_id', null)
.order('inicio_em', { ascending: false });
if (err) throw err;
eventos.value = (data || []).map(normalizeEvent);
} catch (e) {
error.value = e?.message || 'Erro ao carregar sessões';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaTodasSessoesPaciente]', e);
} finally {
loading.value = false;
}
}
function reset() {
eventos.value = [];
error.value = null;
}
return { eventos, loading, error, fetch, reset };
}
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
export function useMelissaEventosHoje() {
const eventos = ref([]);