/* |-------------------------------------------------------------------------- | 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 }; }