86311ef305
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>
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Arquivo: src/composables/useContactPhones.js
|
|
| Data: 2026-04-21
|
|
|
|
|
| Gerenciamento polimórfico de telefones (patients, medicos, etc).
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
import { ref } from 'vue';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
|
|
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();
|
|
|
|
const types = ref([]); // contact_types (system + custom do tenant)
|
|
const phones = ref([]); // contact_phones da entidade atual
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
|
|
async function loadTypes() {
|
|
try {
|
|
const { data } = await supabase
|
|
.from('contact_types')
|
|
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
|
|
.order('position', { ascending: true })
|
|
.order('name', { ascending: true });
|
|
types.value = data || [];
|
|
} catch (e) {
|
|
console.error('[useContactPhones] loadTypes:', e?.message);
|
|
types.value = [];
|
|
}
|
|
}
|
|
|
|
async function loadPhones(entityType, entityId) {
|
|
if (!entityType || !entityId) {
|
|
phones.value = [];
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('contact_phones')
|
|
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
|
|
.eq('entity_type', entityType)
|
|
.eq('entity_id', entityId)
|
|
.order('is_primary', { ascending: false })
|
|
.order('position', { ascending: true });
|
|
if (error) throw error;
|
|
phones.value = data || [];
|
|
} catch (e) {
|
|
console.error('[useContactPhones] loadPhones:', e?.message);
|
|
phones.value = [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
// Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar
|
|
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
|
|
const q = supabase
|
|
.from('contact_phones')
|
|
.update({ is_primary: false })
|
|
.eq('entity_type', entityType)
|
|
.eq('entity_id', entityId)
|
|
.eq('is_primary', true);
|
|
if (exceptId) q.neq('id', exceptId);
|
|
await q;
|
|
}
|
|
|
|
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
|
|
const digits = normalizeDigits(number);
|
|
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
|
|
if (is_primary) {
|
|
await unsetOtherPrimaries(entityType, entityId);
|
|
} else if (phones.value.length === 0) {
|
|
// Primeiro telefone → vira primary automaticamente
|
|
is_primary = true;
|
|
}
|
|
|
|
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
|
|
const { data, error } = await supabase
|
|
.from('contact_phones')
|
|
.insert({
|
|
tenant_id: tenantId,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
contact_type_id,
|
|
number: digits,
|
|
is_primary,
|
|
whatsapp_linked_at,
|
|
notes,
|
|
position: maxPos + 10
|
|
})
|
|
.select()
|
|
.single();
|
|
if (error) throw error;
|
|
await loadPhones(entityType, entityId);
|
|
return { ok: true, phone: data };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'add_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if (sanitized.is_primary === true) {
|
|
await unsetOtherPrimaries(entityType, entityId, id);
|
|
}
|
|
|
|
const { error } = await supabase
|
|
.from('contact_phones')
|
|
.update(sanitized)
|
|
.eq('id', id);
|
|
if (error) throw error;
|
|
await loadPhones(entityType, entityId);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'update_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
const { error } = await supabase.from('contact_phones').delete().eq('id', id);
|
|
if (error) throw error;
|
|
|
|
// Se removeu o primary, promove o próximo pra primary
|
|
if (wasPrimary) {
|
|
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
if (remaining.length > 0) {
|
|
await supabase
|
|
.from('contact_phones')
|
|
.update({ is_primary: true })
|
|
.eq('id', remaining[0].id);
|
|
}
|
|
}
|
|
await loadPhones(entityType, entityId);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'remove_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
|
|
function typeBySlug(slug) {
|
|
return types.value.find((t) => t.slug === slug);
|
|
}
|
|
function typeById(id) {
|
|
return types.value.find((t) => t.id === id);
|
|
}
|
|
|
|
return {
|
|
types,
|
|
phones,
|
|
loading,
|
|
saving,
|
|
loadTypes,
|
|
loadPhones,
|
|
addPhone,
|
|
updatePhone,
|
|
removePhone,
|
|
setPrimary,
|
|
flushPending,
|
|
typeBySlug,
|
|
typeById
|
|
};
|
|
}
|