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:
@@ -19,6 +19,14 @@ function normalizeEmail(raw) {
|
||||
return String(raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Mesma estratégia do useContactPhones: emails sem entidade ficam em
|
||||
// memória com id 'pending_*' até flushPending gravar tudo em lote.
|
||||
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 useContactEmails() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
@@ -77,12 +85,34 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = normalizeEmail(email);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
|
||||
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
|
||||
|
||||
// Modo pendente: entidade ainda não existe — mantém em memória até flushPending.
|
||||
if (!entityType || !entityId) {
|
||||
const wasFirst = emails.value.length === 0;
|
||||
if (is_primary || wasFirst) {
|
||||
emails.value.forEach((e) => { e.is_primary = false; });
|
||||
is_primary = true;
|
||||
}
|
||||
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
||||
const tempEmail = {
|
||||
id: genPendingId(),
|
||||
contact_email_type_id,
|
||||
email: clean,
|
||||
is_primary,
|
||||
notes,
|
||||
position: maxPos + 10,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
emails.value = [...emails.value, tempEmail];
|
||||
return { ok: true, email: tempEmail };
|
||||
}
|
||||
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (is_primary) {
|
||||
@@ -117,15 +147,28 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function updateEmail(entityType, entityId, id, patch) {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
|
||||
// Pending: muta no array local, sem DB
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const idx = emails.value.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return { ok: false, error: 'not_found' };
|
||||
if (sanitized.is_primary === true) {
|
||||
emails.value.forEach((e, i) => { if (i !== idx) e.is_primary = false; });
|
||||
}
|
||||
emails.value[idx] = { ...emails.value[idx], ...sanitized };
|
||||
emails.value = [...emails.value];
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
@@ -141,6 +184,19 @@ export function useContactEmails() {
|
||||
}
|
||||
|
||||
async function removeEmail(entityType, entityId, id) {
|
||||
// Pending: tira do array + promove próximo a primary se necessário
|
||||
if (isPending(id) || !entityType || !entityId) {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
emails.value = emails.value.filter((e) => e.id !== id);
|
||||
if (wasPrimary && emails.value.length > 0) {
|
||||
const remaining = [...emails.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
emails.value = emails.value.map((e) =>
|
||||
e.id === remaining[0].id ? { ...e, is_primary: true } : e
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
@@ -161,6 +217,36 @@ export function useContactEmails() {
|
||||
}
|
||||
}
|
||||
|
||||
// Grava em lote os emails que estavam em modo pendente.
|
||||
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 = emails.value.filter((e) => isPending(e.id));
|
||||
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
||||
saving.value = true;
|
||||
try {
|
||||
const rows = pendingItems.map((e) => ({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id: e.contact_email_type_id,
|
||||
email: normalizeEmail(e.email),
|
||||
is_primary: !!e.is_primary,
|
||||
notes: e.notes || null,
|
||||
position: e.position
|
||||
}));
|
||||
const { error } = await supabase.from('contact_emails').insert(rows);
|
||||
if (error) throw error;
|
||||
await loadEmails(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 updateEmail(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
@@ -179,6 +265,7 @@ export function useContactEmails() {
|
||||
updateEmail,
|
||||
removeEmail,
|
||||
setPrimary,
|
||||
flushPending,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user