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
@@ -24,7 +24,7 @@
* Os handlers exibem toasts (success/warn) — o composable assume que os
* componentes consumidores já registraram `<Toast />` e `<ConfirmDialog />`.
*/
import { ref, computed, watch, onMounted } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
@@ -322,11 +322,38 @@ export function useMelissaAgenda() {
// ── Inicialização ───────────────────────────────────────────
onMounted(async () => {
await loadSettings();
await loadDeterminedCommitments();
const tid = clinicTenantId.value;
if (tid) await loadFeriadosBase(tid);
});
// Refetch settings + workRules quando o user salva jornada/ritmo/online
// em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline
// do resumo continuaria mostrando o range antigo até reload da página.
function _onSettingsSaved() {
loadSettings();
}
onMounted(() => {
window.addEventListener('agenda:settings-saved', _onSettingsSaved);
});
onBeforeUnmount(() => {
window.removeEventListener('agenda:settings-saved', _onSettingsSaved);
});
// Commitments + feriados dependem do tenant. Em refresh "frio", o
// tenantStore ainda não terminou de hidratar quando o composable
// monta, e clinicTenantId fica null. loadDeterminedCommitments faz
// bail-out silencioso quando tenantId é vazio (rows = [], sem retry)
// — daí o "às vezes" do bug onde commitmentOptions chegava vazio no
// AgendaEventDialog. Watch com immediate: true dispara já se o tenant
// estiver pronto, ou no momento exato em que ele aparecer.
watch(
clinicTenantId,
async (tid) => {
if (!tid) return;
await loadDeterminedCommitments();
await loadFeriadosBase(tid);
},
{ immediate: true }
);
// Reload quando view muda OU quando settings/ownerId aparece
watch([viewStart, viewEnd], _reloadRange);
watch(ownerId, (v) => {
@@ -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([]);
@@ -0,0 +1,183 @@
/*
* useMelissaPacientesAside — paginação async DEDICADA pra coluna direita
* da MelissaAgenda (lista de "Pacientes" lá no aside).
* --------------------------------------------------
* Por que existe (em vez de reaproveitar `useMelissaPacientes`):
* - useMelissaPacientes carrega TUDO num array só (até 1000) porque outras
* partes do sistema dependem da lista completa em memória (lookup por ID
* em eventos da agenda, página MelissaPacientes, cards de resumo).
* - A coluna do aside só precisa renderizar 6 por vez. Pra clínicas com
* milhares de pacientes, faz sentido essa coluna ir ao banco a cada
* página/busca em vez de paginar client-side em cima de um array gigante.
*
* Compromisso: a ordenação é alfabética (server-side por nome_completo). O
* destaque visual de "novo paciente" continua funcionando (compara created_at
* < 7 dias no consumer), mas pacientes novos NÃO são mais empurrados pro
* topo da lista — eles aparecem na ordem alfabética normal. Pra ver os mais
* recentes, usuário precisa filtrar/buscar pelo nome.
*
* Sanitização (regra do projeto):
* - busca trimada e capada em 100 chars
* - wildcards LIKE (%, _) são escapados antes de irem pro .ilike()
*
* Race-safety:
* - sequence number (_seq) ignora respostas tardias de queries antigas
* quando o usuário troca de página/digita rápido.
*/
import { ref, computed, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const SEARCH_MAX_LEN = 100;
const DEBOUNCE_MS = 250;
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);
}
// Escapa wildcards do LIKE/ILIKE pra evitar que o usuário injete
// padrões de busca não intencionais ao digitar % ou _.
function escapeLike(s) {
return String(s).replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}
/**
* @param {object} opts
* @param {import('vue').Ref<number>} opts.pagina ref 1-based
* @param {import('vue').Ref<string>} opts.busca ref de string (texto livre)
* @param {number} [opts.porPagina=6] tamanho da página
*/
export function useMelissaPacientesAside(opts) {
const { pagina, busca, porPagina = 6 } = opts;
const tenantStore = useTenantStore();
const pacientes = ref([]);
const total = ref(0);
const loading = ref(false);
const error = ref(null);
const _uid = ref(null);
const _seq = ref(0);
let _debounceTimer = 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 _fetch() {
const seq = ++_seq.value;
const userId = await _ensureUid();
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!userId || !tid) {
if (seq !== _seq.value) return;
pacientes.value = [];
total.value = 0;
return;
}
// Sanitização da busca: trim + cap + escape de wildcards LIKE.
const rawQ = String(busca.value || '').trim().slice(0, SEARCH_MAX_LEN);
const hasQ = rawQ.length > 0;
const start = Math.max(0, (pagina.value - 1) * porPagina);
const end = start + porPagina - 1;
loading.value = true;
error.value = null;
try {
let q = supabase
.from('patients')
.select(
'id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento',
{ count: 'exact' }
)
.eq('owner_id', userId)
.eq('tenant_id', tid)
// Mesmo critério do useMelissaPacientes original (onlyActive=true).
// DB tem valores variados ('ativo'/'Ativo'/'active'); aceita os 3.
.in('status', ['ativo', 'Ativo', 'active'])
.order('nome_completo', { ascending: true })
.range(start, end);
if (hasQ) {
q = q.ilike('nome_completo', `%${escapeLike(rawQ)}%`);
}
const { data, error: err, count } = await q;
if (err) throw err;
// Race-guard: outra chamada disparou enquanto esperávamos a resposta.
if (seq !== _seq.value) return;
pacientes.value = (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,
data_nascimento: r.data_nascimento || null
}));
total.value = count ?? 0;
} catch (e) {
if (seq !== _seq.value) return;
error.value = e?.message || 'Erro ao carregar pacientes';
pacientes.value = [];
total.value = 0;
// eslint-disable-next-line no-console
console.warn('[useMelissaPacientesAside]', e);
} finally {
if (seq === _seq.value) loading.value = false;
}
}
function _scheduleFetch({ debounce }) {
if (_debounceTimer) {
clearTimeout(_debounceTimer);
_debounceTimer = null;
}
if (debounce) {
_debounceTimer = setTimeout(() => {
_debounceTimer = null;
_fetch();
}, DEBOUNCE_MS);
} else {
_fetch();
}
}
// Página muda → fetch imediato (clique no paginator é deliberado).
watch(pagina, () => _scheduleFetch({ debounce: false }), { immediate: true });
// Busca muda → debounce (usuário digitando).
watch(busca, () => {
// Reset implícito: ao buscar, qualquer página > 1 deve voltar pra 1.
// Como `pagina` é refs do consumer, não mexemos aqui — o consumer faz isso.
_scheduleFetch({ debounce: true });
});
const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / porPagina)));
return {
pacientes,
total,
totalPaginas,
loading,
error,
refetch: () => _scheduleFetch({ debounce: false })
};
}
@@ -0,0 +1,71 @@
/*
* useMelissaWhatsapp — agregado leve pro card "WhatsApp" do resumo Melissa.
*
* Lê da view `conversation_threads` (mesma fonte do drawer/kanban):
* - count = soma de unread_count em threads WhatsApp não-lidas
* - top1 = thread mais recente com mensagens não-lidas (preview)
*
* Filtra channel='whatsapp' pra coerência com o título do card. Inclui
* só threads com unread_count > 0 (limit 50 — payload pequeno e cobre
* praticamente qualquer clínica; se passar disso, o count fica ligeiramente
* subestimado mas o card já cumpre o papel de "alerta visual").
*
* Sem realtime no MVP — refetch manual via `refetch()`. Quando quiser
* atualização instantânea, plugar a subscription do `useConversations`
* (channel `conv_msg_tenant_<tid>` em conversation_messages INSERT).
*/
import { ref, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const EMPTY = { count: 0, ultimaMsg: '', ultimoNome: '', ultimaEm: null };
export function useMelissaWhatsapp() {
const summary = ref({ ...EMPTY });
const loading = ref(false);
const error = ref(null);
async function fetch() {
const tenantStore = useTenantStore();
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tid) { summary.value = { ...EMPTY }; return; }
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_threads')
.select('patient_name, contact_number, unread_count, last_message_body, last_message_at, last_message_direction')
.eq('tenant_id', tid)
.eq('channel', 'whatsapp')
.gt('unread_count', 0)
.order('last_message_at', { ascending: false })
.limit(50);
if (err) throw err;
const rows = data || [];
const totalUnread = rows.reduce((s, t) => s + Number(t.unread_count || 0), 0);
const top = rows[0] || null;
summary.value = {
count: totalUnread,
ultimaMsg: String(top?.last_message_body || '').trim(),
ultimoNome: String(top?.patient_name || top?.contact_number || '').trim() || '—',
ultimaEm: top?.last_message_at || null
};
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[useMelissaWhatsapp]', e);
error.value = e?.message || 'Erro ao carregar WhatsApp';
summary.value = { ...EMPTY };
} finally {
loading.value = false;
}
}
onMounted(fetch);
return { summary, loading, error, refetch: fetch };
}