M2: patients — selects + repository + 8 composables refatorados

Modulo 2 da Fase 1 de padronizacao em batch unico. patientsSelects.js
nova com 11 constantes de select. patientsRepository.js estendido com
~15 funcoes novas (markIntakeConverted, list/get/update por
contexto, etc). 8 composables refatorados em paralelo (usePatients,
useDetail, Financial, Sessions, Messages, Documents, Recurrences,
SupportContacts) — zero supabase.from() em qualquer composable de
patients. _lastPatientId movido pra DENTRO das functions nos 3
composables que tinham. CadastrosRecebidosPage + MelissaCadastros
Recebidos pegam carona dos selects. Aguarda teste batch consolidado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:20:08 -03:00
parent 27467bbb68
commit 1c2a2b6e19
12 changed files with 687 additions and 508 deletions
@@ -4,62 +4,18 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientDetail.js
|
| Composable de detalhe completo de paciente — patient row + grupos + tags
| Extraido do PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
| Mantem a mesma logica original (Promise.all em 2 etapas, RLS-aware).
| Detalhe completo de paciente — patient row + grupos + tags. Extraido do
| PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
| Mantem a mesma logica (Promise.all em 2 etapas) — agora via repository.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
async function getPatientById(id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw error;
return data;
}
async function getPatientRelations(id) {
const { data: g, error: ge } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.eq('patient_id', id);
if (ge) throw ge;
const { data: t, error: te } = await supabase
.from('patient_patient_tag')
.select('tag_id')
.eq('patient_id', id);
if (te) throw te;
return {
groupIds: (g || []).map((x) => x.patient_group_id).filter(Boolean),
tagIds: (t || []).map((x) => x.tag_id).filter(Boolean)
};
}
async function getGroupsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase
.from('patient_groups')
.select('id, nome')
.in('id', ids)
.order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
}
async function getTagsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase
.from('patient_tags')
.select('id, nome, cor')
.in('id', ids)
.order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
import {
getPatientById,
getPatientRelations,
getGroupsByIds,
getTagsByIds
} from '@/features/patients/services/patientsRepository';
export function usePatientDetail() {
const patient = ref(null);
@@ -4,13 +4,14 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientDocuments.js
|
| Documentos do paciente — carrega so os campos pra KPIs (count, tipo,
| ultima atualizacao). O detalhe completo fica em DocumentsListPage que
| tem composable proprio. Filtra deletados (deleted_at IS NULL).
| Documentos do paciente — campos pra KPIs (count, tipo, última atualização).
| O detalhe completo fica em DocumentsListPage. Filtra deletados.
|
| I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { listDocumentsByPatient } from '@/features/patients/services/patientsRepository';
import { fmtSize, DOC_TYPE_LABEL } from '@/features/patients/utils/patientFormatters';
export function usePatientDocuments() {
@@ -27,15 +28,7 @@ export function usePatientDocuments() {
error.value = '';
documents.value = [];
try {
const { data, error: err } = await supabase
.from('documents')
.select('id, tipo_documento, created_at, status_revisao, tamanho_bytes')
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
documents.value = data || [];
documents.value = await listDocumentsByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar documentos.';
documents.value = [];
@@ -45,9 +38,7 @@ export function usePatientDocuments() {
}
const total = computed(() => documents.value.length);
const totalBytes = computed(() =>
documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0)
);
const totalBytes = computed(() => documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0));
const tiposCount = computed(() => {
const map = new Map();
documents.value.forEach((d) => {
@@ -58,10 +49,6 @@ export function usePatientDocuments() {
});
const ultimo = computed(() => documents.value[0] || null);
/**
* Tipo de documento mais comum (alimenta KPI "Mais comum").
* Retorna { tipo, count, label } ou null se vazio.
*/
const topType = computed(() => {
const por = {};
for (const d of documents.value) {
@@ -74,16 +61,8 @@ export function usePatientDocuments() {
return { tipo, count, label: DOC_TYPE_LABEL[tipo] || tipo };
});
/**
* Count de documentos com status_revisao === 'pendente'.
*/
const pendentes = computed(() =>
documents.value.filter((d) => d.status_revisao === 'pendente').length
);
const pendentes = computed(() => documents.value.filter((d) => d.status_revisao === 'pendente').length);
/**
* Tamanho total formatado em string legivel (B/KB/MB/GB).
*/
const sizeTotalFormatted = computed(() => fmtSize(totalBytes.value));
return {
@@ -4,20 +4,26 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientFinancial.js
|
| Lancamentos financeiros (financial_records) do paciente. Filtra type=receita,
| limita 100. Schema: paid_at NULL = pendente, preenchido = pago.
| Lançamentos financeiros (financial_records) do paciente. type=receita,
| limit 100. paid_at NULL = pendente, preenchido = pago.
| "Vencido" = paid_at IS NULL AND due_date < hoje.
| Computeds derivados: kpis (em aberto, atrasado, total, ultimo pago).
| Computeds: kpis (em aberto, atrasado, total, último pago, status financeiro).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import {
listFinancialRecordsByPatient,
createFinancialRecord,
markFinancialRecordPaid,
markFinancialRecordUnpaid
} from '@/features/patients/services/patientsRepository';
export function usePatientFinancial() {
const records = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false);
// Dentro da function — não vaza entre instâncias (audit alta resolvida)
let _lastPatientId = null;
async function load(patientId) {
@@ -30,15 +36,7 @@ export function usePatientFinancial() {
error.value = '';
records.value = [];
try {
const { data, error: err } = await supabase
.from('financial_records')
.select('id, type, amount, due_date, paid_at, description, payment_method, category, created_at')
.eq('patient_id', patientId)
.eq('type', 'receita')
.order('created_at', { ascending: false })
.limit(100);
if (err) throw err;
records.value = data || [];
records.value = await listFinancialRecordsByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar lançamentos.';
records.value = [];
@@ -73,11 +71,7 @@ export function usePatientFinancial() {
});
/**
* Status financeiro detalhado pra KPI da Visao Geral.
* - emDia: nenhum pendente vencido (paid_at NULL && due_date < hoje)
* - proxVenc: proximo pendente com due_date no futuro
* - totalPendente / totalPago: somatorio
* - vencidos: count de pendentes vencidos
* Status financeiro detalhado pra KPI da Visão Geral.
*/
const statusFinanceiro = computed(() => {
const recs = records.value;
@@ -90,9 +84,10 @@ export function usePatientFinancial() {
const vencidos = pendentes.filter(
(r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now
);
const proxVenc = pendentes
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
const proxVenc =
pendentes
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
const totalPendente = pendentes.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
return {
@@ -104,10 +99,6 @@ export function usePatientFinancial() {
};
});
/**
* Lancamentos ordenados DESC por due_date (fallback created_at).
* Mais recente primeiro pra alimentar a tabela da Tab Financeiro.
*/
const recordsOrdenados = computed(() =>
[...records.value].sort((a, b) => {
const da = a.due_date || a.created_at;
@@ -116,19 +107,11 @@ export function usePatientFinancial() {
})
);
/**
* Marca um lancamento como pago (paid_at = now). Auto-reload.
* Retorna {ok, error?}.
*/
async function markPaid(recordId) {
if (!recordId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('financial_records')
.update({ paid_at: new Date().toISOString() })
.eq('id', recordId);
if (err) throw err;
await markFinancialRecordPaid(recordId);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -138,49 +121,14 @@ export function usePatientFinancial() {
}
}
/**
* Cria um novo lancamento manual (type=receita) pro paciente.
* Insere com tenant_id + owner_id resolvidos via auth/tenant store.
* Auto-reload ao final pra refletir nos KPIs e tabela.
*
* payload: { description, amount, due_date, payment_method? }
* Retorna {ok, data?, error?}.
*/
async function createRecord(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
return { ok: false, error: 'Valor invalido' };
return { ok: false, error: 'Valor inválido' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
// tenant_id: tenta tenantStore lazy import, fallback null (RLS
// via owner_id ainda permite insert).
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
const row = {
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
type: 'receita',
amount: Number(payload.amount),
due_date: payload.due_date || null,
description: String(payload.description || '').trim() || null,
payment_method: payload.payment_method || null,
paid_at: null
};
const { data, error: err } = await supabase
.from('financial_records')
.insert([row])
.select()
.single();
if (err) throw err;
const data = await createFinancialRecord(patientId, payload);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
@@ -190,18 +138,11 @@ export function usePatientFinancial() {
}
}
/**
* Reverte: remove paid_at (volta pra pendente). Auto-reload.
*/
async function markUnpaid(recordId) {
if (!recordId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('financial_records')
.update({ paid_at: null })
.eq('id', recordId);
if (err) throw err;
await markFinancialRecordUnpaid(recordId);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -4,13 +4,15 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientMessages.js
|
| Mensagens de conversa do paciente. Carrega 200 mais recentes (in+out)
| pra alimentar o card "Ultimas mensagens" (Visao Geral, top 4) e os
| KPIs da aba Conversas. Conversa completa fica no PatientConversationsTab.
| Mensagens de conversa do paciente. 200 mais recentes (in+out) pra alimentar
| o card "Últimas mensagens" (top 4) e KPIs da aba Conversas.
| Conversa completa fica no PatientConversationsTab.
|
| I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { listMessagesByPatient } from '@/features/patients/services/patientsRepository';
export function usePatientMessages() {
const messages = ref([]);
@@ -26,14 +28,7 @@ export function usePatientMessages() {
error.value = '';
messages.value = [];
try {
const { data, error: err } = await supabase
.from('conversation_messages')
.select('id, body, direction, created_at, channel, kanban_status')
.eq('patient_id', patientId)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
messages.value = data || [];
messages.value = await listMessagesByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar mensagens.';
messages.value = [];
@@ -43,18 +38,11 @@ export function usePatientMessages() {
}
const recentes = computed(() => messages.value.slice(0, 4));
const totalIn = computed(() =>
messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length
);
const totalOut = computed(() =>
messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length
);
const totalIn = computed(() => messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length);
const totalOut = computed(() => messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length);
const ultimaMensagem = computed(() => messages.value[0] || null);
const primeiraMensagem = computed(() => messages.value[messages.value.length - 1] || null);
/**
* Canais unicos usados nas mensagens (whatsapp, sms, email).
*/
const canais = computed(() => {
const set = new Set();
for (const m of messages.value) if (m.channel) set.add(m.channel);
@@ -4,15 +4,18 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientRecurrences.js
|
| Carrega regras de recorrencia (recurrence_rules) filtradas por paciente.
| Usado pela Tab Agenda do MelissaPaciente pra mostrar "este paciente tem
| sessao toda segunda 14h" e dar acoes inline (cancelar/reativar).
| Regras de recorrência (recurrence_rules) do paciente. Usado pela Tab Agenda
| do MelissaPaciente pra mostrar "este paciente tem sessão toda segunda 14h"
| e ações inline (cancelar/reativar).
|
| Mutations espelham o pattern de MelissaRecorrencias.vue.
| I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import {
listRecurrencesByPatient,
updateRecurrenceStatus
} from '@/features/patients/services/patientsRepository';
export function usePatientRecurrences() {
const rules = ref([]);
@@ -31,15 +34,9 @@ export function usePatientRecurrences() {
error.value = '';
rules.value = [];
try {
const { data, error: err } = await supabase
.from('recurrence_rules')
.select('*')
.eq('patient_id', patientId)
.order('start_date', { ascending: false });
if (err) throw err;
rules.value = data || [];
rules.value = await listRecurrencesByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar recorrencias.';
error.value = e?.message || 'Falha ao carregar recorrências.';
rules.value = [];
} finally {
loading.value = false;
@@ -50,11 +47,7 @@ export function usePatientRecurrences() {
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', ruleId);
if (err) throw err;
await updateRecurrenceStatus(ruleId, 'cancelado');
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -68,11 +61,7 @@ export function usePatientRecurrences() {
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'ativo', updated_at: new Date().toISOString() })
.eq('id', ruleId);
if (err) throw err;
await updateRecurrenceStatus(ruleId, 'ativo');
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -4,23 +4,32 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientSessions.js
|
| Carrega sessoes (agenda_eventos) do paciente. Limit 100 mais recentes
| ordenadas desc por inicio_em. Compativel com a logica original do
| PatientProntuario.vue.
| Carrega sessões (agenda_eventos) do paciente. Limit 100 reais + expansão
| de recorrências do owner dentro de uma janela de 18 meses. Merge desc por
| inicio_em pra alimentar prontuário/timeline.
|
| I/O delegada ao patientsRepository. Expansão de recorrência continua via
| useRecurrence (composable da agenda — não é supabase direto, é orquestração).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import {
listSessionsByPatient,
createPatientSession,
updatePatientSessionStatus,
findSessionByRecurrence
} from '@/features/patients/services/patientsRepository';
export function usePatientSessions() {
const sessions = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false); // mutations em curso (updateStatus etc)
const busy = ref(false);
// State per-instância — não vaza
let _lastPatientId = null;
// Instancia local — refs internos (rules/exceptions) ficam isolados deste consumidor.
const { loadAndExpand } = useRecurrence();
async function load(patientId) {
@@ -33,23 +42,14 @@ export function usePatientSessions() {
error.value = '';
sessions.value = [];
try {
// 1. Linhas reais — `recurrence_id`/`recurrence_date` inclusos pra
// mergeWithStoredSessions deduplicar virtuais de sessões já materializadas.
const { data, error: err } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes, patient_id, recurrence_id, recurrence_date')
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100);
if (err) throw err;
const realRows = data || [];
// 1. Linhas reais via repository
const realRows = await listSessionsByPatient(patientId);
// 2. Expande recorrências do owner + filtra só as deste paciente.
// Range default: 6 meses atrás → 12 meses à frente (cobre histórico
// recente + ~1 ano de séries semanais/quinzenais futuras). Sem expansão,
// sessão 1 aparece (materializada) mas as N-1 virtuais ficam invisíveis.
// 2. Expande recorrências do owner + filtra pra este paciente.
// Range: 6 meses atrás → 12 meses à frente (histórico + ~1 ano de séries futuras).
let virtualOccs = [];
try {
// auth.getUser é context, não data query — pode ficar inline.
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id || null;
if (ownerId) {
@@ -57,7 +57,9 @@ export function usePatientSessions() {
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
} catch {
/* sem tenant store — segue */
}
const now = new Date();
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 6, 1);
@@ -67,12 +69,12 @@ export function usePatientSessions() {
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
}
} catch (e) {
// Fallback silencioso — UI segue funcional só com sessões reais.
// Fallback silencioso — UI segue só com sessões reais.
// eslint-disable-next-line no-console
console.warn('[usePatientSessions] recurrence expand falhou:', e);
}
// 3. Merge desc por inicio_em (mantém contrato do composable original).
// 3. Merge desc por inicio_em
const merged = [...realRows, ...virtualOccs];
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
sessions.value = merged;
@@ -84,122 +86,63 @@ export function usePatientSessions() {
}
}
// Helpers derivados — proxima sessao agendada e status corrente
// ─── Computeds derivados ────────────────────────────────
const proximaSessao = computed(() => {
const now = Date.now();
return [...sessions.value]
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null;
return (
[...sessions.value]
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null
);
});
const ultimaSessao = computed(() => {
const now = Date.now();
return sessions.value
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null;
return (
sessions.value
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null
);
});
const totalSessoes = computed(() => sessions.value.length);
// Conta status com regex pra cobrir variantes pt-br
// (realizada/realizado/presente; falta/faltou; cancelada/cancelado/remarcada).
const totalRealizadas = computed(() =>
sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length
);
const totalFaltas = computed(() =>
sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length
);
const totalCanceladas = computed(() =>
sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length
);
const totalRealizadas = computed(() => sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length);
const totalFaltas = computed(() => sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length);
const totalCanceladas = computed(() => sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length);
/**
* Top 6 sessoes "atendidas" (qualquer status que indica encontro: realizado,
* faltou, cancelado, remarcado) — alimenta a Timeline da Visao Geral.
*/
const ultimasAtendidas = computed(() =>
sessions.value
.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || '')))
.slice(0, 6)
sessions.value.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || ''))).slice(0, 6)
);
/**
* Cria uma nova sessao na agenda do paciente.
*
* payload: {
* inicio_em: ISO timestamp,
* fim_em: ISO timestamp,
* tipo: 'sessao' | 'primeira' | 'retorno' | etc,
* modalidade: 'presencial' | 'online',
* titulo?: string,
* titulo_custom?: string,
* observacoes?: string
* }
* Retorna {ok, data?, error?}.
*/
// ─── Mutations ─────────────────────────────────────────
async function createSession(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.inicio_em || !payload?.fim_em) {
return { ok: false, error: 'Inicio/fim obrigatorios' };
return { ok: false, error: 'Início/fim obrigatórios' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
const row = {
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
inicio_em: payload.inicio_em,
fim_em: payload.fim_em,
status: 'agendado',
modalidade: payload.modalidade || 'presencial',
tipo: payload.tipo || 'sessao',
titulo: String(payload.titulo || '').trim() || null,
titulo_custom: String(payload.titulo_custom || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null
};
const { data, error: err } = await supabase
.from('agenda_eventos')
.insert([row])
.select()
.single();
if (err) throw err;
const data = await createPatientSession(patientId, payload);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao agendar sessao' };
return { ok: false, error: e?.message || 'Erro ao agendar sessão' };
} finally {
busy.value = false;
}
}
/**
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
* ao final pra refletir o novo estado nos computeds derivados.
*
* Aceita string (UUID legado) OU a row inteira da sessão. Quando vier a row
* e ela for ocorrência virtual (is_occurrence=true, id `rec::ruleId::date`),
* MATERIALIZA primeiro: cria/encontra a linha real em agenda_eventos com
* recurrence_id+recurrence_date apontando pra regra, depois aplica o status.
* Sem isso o UPDATE falha com "invalid input syntax for type uuid" porque
* o id virtual nunca existiu no banco. Espelha o pattern de
* useMelissaAgenda.onUpdateSeriesEvent (L808-850).
*
* Retorna {ok: true} ou {ok: false, error: msg}.
* Atualiza status — aceita UUID string OU row inteira. Se virtual
* (is_occurrence), materializa antes via createPatientSession.
*/
async function updateStatus(sessionOrId, novoStatus) {
if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
// Caminho A — string UUID legado ou row real (id é UUID real).
const isObject = typeof sessionOrId === 'object' && sessionOrId !== null;
const isVirtual = isObject && !!sessionOrId.is_occurrence;
@@ -208,16 +151,12 @@ export function usePatientSessions() {
if (!realId || typeof realId !== 'string' || realId.startsWith('rec::')) {
return { ok: false, error: 'ID inválido pra atualizar status (virtual sem row).' };
}
const { error: err } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', realId);
if (err) throw err;
await updatePatientSessionStatus(realId, novoStatus);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
}
// Caminho B — ocorrência virtual: materializar antes de atualizar.
// Virtual: materializar antes
const row = sessionOrId;
const rid = row.recurrence_id;
const rDate = row.recurrence_date || row.original_date || String(row.inicio_em || '').slice(0, 10);
@@ -226,52 +165,25 @@ export function usePatientSessions() {
return { ok: false, error: 'Ocorrência sem recurrence_id/date — não dá pra materializar.' };
}
// Já existe row materializada (mesmo recurrence_id+date)? Usa ela.
const { data: existing, error: exErr } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (exErr) throw exErr;
const existing = await findSessionByRecurrence(rid, rDate);
if (existing?.id) {
const { error: upErr } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', existing.id);
if (upErr) throw upErr;
await updatePatientSessionStatus(existing.id, novoStatus);
} else {
// Materializa NOVA row a partir da virtual. Owner/tenant via auth+store.
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id || null;
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem store — segue */ }
const newRow = {
owner_id: ownerId,
tenant_id: tenantId,
recurrence_id: rid,
recurrence_date: rDate,
patient_id: row.patient_id || row.paciente_id || _lastPatientId,
tipo: row.tipo || 'sessao',
status: novoStatus,
// Cria row real materializada (createPatientSession suporta recurrence_id/date)
await createPatientSession(row.patient_id || row.paciente_id || _lastPatientId, {
inicio_em: row.inicio_em,
fim_em: row.fim_em,
status: novoStatus,
modalidade: row.modalidade || 'presencial',
titulo: row.titulo || null,
titulo_custom: row.titulo_custom || null,
observacoes: row.observacoes || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
};
const { error: insErr } = await supabase
.from('agenda_eventos')
.insert([newRow]);
if (insErr) throw insErr;
tipo: row.tipo || 'sessao',
titulo: row.titulo,
titulo_custom: row.titulo_custom,
observacoes: row.observacoes,
recurrence_id: rid,
recurrence_date: rDate,
determined_commitment_id: row.determined_commitment_id,
price: row.price
});
}
if (_lastPatientId) await load(_lastPatientId);
@@ -3,13 +3,16 @@
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientSupportContacts.js
| V#9 — composable de contatos de suporte do paciente (responsável, parente,
| amigo). Encapsula CRUD + estado reativo.
| V#9 — contatos de suporte do paciente (responsável, parente, amigo).
| Encapsula CRUD + estado reativo. I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { digitsOnly, fmtPhone } from '@/utils/validators';
import {
listSupportContactsByPatient,
replacePatientSupportContacts
} from '@/features/patients/services/patientsRepository';
function novoContato() {
return {
@@ -55,12 +58,7 @@ export function usePatientSupportContacts() {
}
loading.value = true;
try {
const { data, error } = await supabase
.from('patient_support_contacts')
.select('*')
.eq('patient_id', patientId)
.order('is_primario', { ascending: false });
if (error) throw error;
const data = await listSupportContactsByPatient(patientId);
contatos.value = (data || []).map((c) => ({
_k: c.id,
nome: c.nome || '',
@@ -71,6 +69,7 @@ export function usePatientSupportContacts() {
is_primario: !!c.is_primario
}));
} catch {
// Fallback silencioso pra não quebrar prontuário se tabela estiver vazia/RLS
contatos.value = [];
} finally {
loading.value = false;
@@ -79,26 +78,13 @@ export function usePatientSupportContacts() {
/**
* Substitui contatos do paciente: deleta tudo do owner + reinserta os com nome.
* @param {string} patientId
* @param {string} tenantId
* @param {string} ownerId
*/
async function save(patientId, tenantId, ownerId) {
if (!patientId) throw new Error('patientId obrigatório');
const { error: del } = await supabase
.from('patient_support_contacts')
.delete()
.eq('patient_id', patientId)
.eq('owner_id', ownerId);
if (del) throw del;
const rows = contatos.value
.filter((c) => c.nome.trim())
.map((c) => ({
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
nome: c.nome.trim() || null,
relacao: c.relacao || null,
tipo: c.tipo || null,
@@ -107,9 +93,8 @@ export function usePatientSupportContacts() {
is_primario: !!c.is_primario
}));
if (!rows.length) return;
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
if (ins) throw ins;
// Repository injeta patient_id, owner_id, tenant_id em cada row
await replacePatientSupportContacts(patientId, rows, { tenantId, ownerId });
}
return { contatos, loading, add, remove, reset, iniciaisFor, load, save };
@@ -3,8 +3,10 @@
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatients.js
| V#3 — composable que agrega estado reativo (rows/loading/error) e delega
| toda I/O ao patientsRepository. Mesmo padrão de useAgendaEvents.
|
| Thin wrapper sobre patientsRepository (composable-blueprint Tipo A).
| Toda I/O delegada ao repository. State reativo: rows, loading, error.
| Mutations re-throw após registrar error.value pra caller decidir UX.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
@@ -19,11 +21,11 @@ import {
export function usePatients() {
const rows = ref([]);
const loading = ref(false);
const error = ref(null);
const error = ref('');
async function load(opts) {
loading.value = true;
error.value = null;
error.value = '';
try {
rows.value = await listPatients(opts);
} catch (e) {
@@ -35,19 +37,55 @@ export function usePatients() {
}
async function getById(id, opts) {
return getPatientById(id, opts);
loading.value = true;
error.value = '';
try {
return await getPatientById(id, opts);
} catch (e) {
error.value = e?.message || 'Erro ao carregar paciente';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
return createPatient(payload);
loading.value = true;
error.value = '';
try {
return await createPatient(payload);
} catch (e) {
error.value = e?.message || 'Erro ao criar paciente';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch, opts) {
return updatePatient(id, patch, opts);
loading.value = true;
error.value = '';
try {
return await updatePatient(id, patch, opts);
} catch (e) {
error.value = e?.message || 'Erro ao atualizar paciente';
throw e;
} finally {
loading.value = false;
}
}
async function remove(id, opts) {
await softDeletePatient(id, opts);
loading.value = true;
error.value = '';
try {
await softDeletePatient(id, opts);
} catch (e) {
error.value = e?.message || 'Erro ao remover paciente';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, load, getById, create, update, remove };