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
+102 -8
View File
@@ -17,6 +17,15 @@ function normalizeDigits(raw) {
return String(raw || '').replace(/\D/g, '');
}
// Telefones em "modo pendente" (entidade ainda não existe no DB) usam ID
// com este prefixo. Permite reusar o mesmo array `phones` na UI sem
// sub-state e detectar quais precisam de INSERT no flushPending.
const PENDING_PREFIX = 'pending_';
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
function genPendingId() {
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
export function useContactPhones() {
const tenantStore = useTenantStore();
@@ -76,12 +85,37 @@ export function useContactPhones() {
}
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const digits = normalizeDigits(number);
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
// Modo pendente: entidade ainda não existe (ex: novo paciente sendo
// cadastrado). Mantém em memória — flushPending grava tudo em lote
// depois que a entidade for criada.
if (!entityType || !entityId) {
const wasFirst = phones.value.length === 0;
if (is_primary || wasFirst) {
phones.value.forEach((p) => { p.is_primary = false; });
is_primary = true;
}
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
const tempPhone = {
id: genPendingId(),
contact_type_id,
number: digits,
is_primary,
whatsapp_linked_at,
notes,
position: maxPos + 10,
created_at: new Date().toISOString()
};
phones.value = [...phones.value, tempPhone];
return { ok: true, phone: tempPhone };
}
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'invalid_context' };
saving.value = true;
try {
// Se marcou como primary, desmarca outros
@@ -119,14 +153,26 @@ export function useContactPhones() {
}
async function updatePhone(entityType, entityId, id, patch) {
const sanitized = { ...patch };
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
return { ok: false, error: 'Telefone inválido' };
}
// Pending: muta no array local sem ir pro DB
if (isPending(id) || !entityType || !entityId) {
const idx = phones.value.findIndex((p) => p.id === id);
if (idx === -1) return { ok: false, error: 'not_found' };
if (sanitized.is_primary === true) {
phones.value.forEach((p, i) => { if (i !== idx) p.is_primary = false; });
}
phones.value[idx] = { ...phones.value[idx], ...sanitized };
phones.value = [...phones.value];
return { ok: true };
}
saving.value = true;
try {
const sanitized = { ...patch };
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
return { ok: false, error: 'Telefone inválido' };
}
if (sanitized.is_primary === true) {
await unsetOtherPrimaries(entityType, entityId, id);
}
@@ -146,6 +192,19 @@ export function useContactPhones() {
}
async function removePhone(entityType, entityId, id) {
// Pending: tira do array local + promove o próximo a primary se sumiu
if (isPending(id) || !entityType || !entityId) {
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
phones.value = phones.value.filter((p) => p.id !== id);
if (wasPrimary && phones.value.length > 0) {
const remaining = [...phones.value].sort((a, b) => (a.position || 0) - (b.position || 0));
phones.value = phones.value.map((p) =>
p.id === remaining[0].id ? { ...p, is_primary: true } : p
);
}
return { ok: true };
}
saving.value = true;
try {
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
@@ -171,6 +230,40 @@ export function useContactPhones() {
}
}
// Grava em lote os telefones que estavam em modo pendente. Chamado pelo
// parent (ex: PatientsCadastroPage) logo depois de criar a entidade no DB.
// Mantém ordem (position) e o flag is_primary do estado local.
async function flushPending(entityType, entityId) {
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'invalid_context' };
const pendingItems = phones.value.filter((p) => isPending(p.id));
if (pendingItems.length === 0) return { ok: true, count: 0 };
saving.value = true;
try {
const rows = pendingItems.map((p) => ({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_type_id: p.contact_type_id,
number: normalizeDigits(p.number),
is_primary: !!p.is_primary,
whatsapp_linked_at: p.whatsapp_linked_at || null,
notes: p.notes || null,
position: p.position
}));
const { error } = await supabase.from('contact_phones').insert(rows);
if (error) throw error;
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
await loadPhones(entityType, entityId);
return { ok: true, count: rows.length };
} catch (e) {
return { ok: false, error: e?.message || 'flush_failed' };
} finally {
saving.value = false;
}
}
async function setPrimary(entityType, entityId, id) {
return updatePhone(entityType, entityId, id, { is_primary: true });
}
@@ -193,6 +286,7 @@ export function useContactPhones() {
updatePhone,
removePhone,
setPrimary,
flushPending,
typeBySlug,
typeById
};