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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user