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:
@@ -17,6 +17,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages — INSERT/UPDATE
|
||||||
|
// extraídos pro repository pra remover duplicação.
|
||||||
|
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
|
||||||
import { logError } from '@/support/supportLogger';
|
import { logError } from '@/support/supportLogger';
|
||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
@@ -402,13 +405,13 @@ async function convertToPatient() {
|
|||||||
if (patientPayload[k] === undefined) delete patientPayload[k];
|
if (patientPayload[k] === undefined) delete patientPayload[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: created, error: insErr } = await supabase.from('patients').insert(patientPayload).select('id').single();
|
// Repository chamadas (Fase 2 — convertToPatient de-dup).
|
||||||
if (insErr) throw insErr;
|
// patientsRepository.createPatient strip owner_id do payload + sempre injeta auth.uid().
|
||||||
|
const created = await createPatient(patientPayload);
|
||||||
const patientId = created?.id;
|
const patientId = created?.id;
|
||||||
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.');
|
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.');
|
||||||
|
|
||||||
const { error: upErr } = await supabase.from('patient_intake_requests').update({ status: 'converted', converted_patient_id: patientId, updated_at: new Date().toISOString() }).eq('id', item.id);
|
await markIntakeConverted(item.id, patientId);
|
||||||
if (upErr) throw upErr;
|
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 });
|
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 });
|
||||||
dlg.value.open = false;
|
dlg.value.open = false;
|
||||||
|
|||||||
@@ -4,62 +4,18 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientDetail.js
|
| Arquivo: src/features/patients/composables/usePatientDetail.js
|
||||||
|
|
|
|
||||||
| Composable de detalhe completo de paciente — patient row + grupos + tags
|
| Detalhe completo de paciente — patient row + grupos + tags. Extraido do
|
||||||
| Extraido do PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
|
| PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
|
||||||
| Mantem a mesma logica original (Promise.all em 2 etapas, RLS-aware).
|
| Mantem a mesma logica (Promise.all em 2 etapas) — agora via repository.
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import {
|
||||||
|
getPatientById,
|
||||||
async function getPatientById(id) {
|
getPatientRelations,
|
||||||
const { data, error } = await supabase
|
getGroupsByIds,
|
||||||
.from('patients')
|
getTagsByIds
|
||||||
.select('*')
|
} from '@/features/patients/services/patientsRepository';
|
||||||
.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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePatientDetail() {
|
export function usePatientDetail() {
|
||||||
const patient = ref(null);
|
const patient = ref(null);
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientDocuments.js
|
| Arquivo: src/features/patients/composables/usePatientDocuments.js
|
||||||
|
|
|
|
||||||
| Documentos do paciente — carrega so os campos pra KPIs (count, tipo,
|
| Documentos do paciente — campos pra KPIs (count, tipo, última atualização).
|
||||||
| ultima atualizacao). O detalhe completo fica em DocumentsListPage que
|
| O detalhe completo fica em DocumentsListPage. Filtra deletados.
|
||||||
| tem composable proprio. Filtra deletados (deleted_at IS NULL).
|
|
|
||||||
|
| I/O delegada ao patientsRepository.
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue';
|
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';
|
import { fmtSize, DOC_TYPE_LABEL } from '@/features/patients/utils/patientFormatters';
|
||||||
|
|
||||||
export function usePatientDocuments() {
|
export function usePatientDocuments() {
|
||||||
@@ -27,15 +28,7 @@ export function usePatientDocuments() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
documents.value = [];
|
documents.value = [];
|
||||||
try {
|
try {
|
||||||
const { data, error: err } = await supabase
|
documents.value = await listDocumentsByPatient(patientId);
|
||||||
.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 || [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Falha ao carregar documentos.';
|
error.value = e?.message || 'Falha ao carregar documentos.';
|
||||||
documents.value = [];
|
documents.value = [];
|
||||||
@@ -45,9 +38,7 @@ export function usePatientDocuments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = computed(() => documents.value.length);
|
const total = computed(() => documents.value.length);
|
||||||
const totalBytes = computed(() =>
|
const totalBytes = computed(() => documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0));
|
||||||
documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0)
|
|
||||||
);
|
|
||||||
const tiposCount = computed(() => {
|
const tiposCount = computed(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
documents.value.forEach((d) => {
|
documents.value.forEach((d) => {
|
||||||
@@ -58,10 +49,6 @@ export function usePatientDocuments() {
|
|||||||
});
|
});
|
||||||
const ultimo = computed(() => documents.value[0] || null);
|
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 topType = computed(() => {
|
||||||
const por = {};
|
const por = {};
|
||||||
for (const d of documents.value) {
|
for (const d of documents.value) {
|
||||||
@@ -74,16 +61,8 @@ export function usePatientDocuments() {
|
|||||||
return { tipo, count, label: DOC_TYPE_LABEL[tipo] || tipo };
|
return { tipo, count, label: DOC_TYPE_LABEL[tipo] || tipo };
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const pendentes = computed(() => documents.value.filter((d) => d.status_revisao === 'pendente').length);
|
||||||
* Count de documentos com status_revisao === 'pendente'.
|
|
||||||
*/
|
|
||||||
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));
|
const sizeTotalFormatted = computed(() => fmtSize(totalBytes.value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,20 +4,26 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientFinancial.js
|
| Arquivo: src/features/patients/composables/usePatientFinancial.js
|
||||||
|
|
|
|
||||||
| Lancamentos financeiros (financial_records) do paciente. Filtra type=receita,
|
| Lançamentos financeiros (financial_records) do paciente. type=receita,
|
||||||
| limita 100. Schema: paid_at NULL = pendente, preenchido = pago.
|
| limit 100. paid_at NULL = pendente, preenchido = pago.
|
||||||
| "Vencido" = paid_at IS NULL AND due_date < hoje.
|
| "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 { ref, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import {
|
||||||
|
listFinancialRecordsByPatient,
|
||||||
|
createFinancialRecord,
|
||||||
|
markFinancialRecordPaid,
|
||||||
|
markFinancialRecordUnpaid
|
||||||
|
} from '@/features/patients/services/patientsRepository';
|
||||||
|
|
||||||
export function usePatientFinancial() {
|
export function usePatientFinancial() {
|
||||||
const records = ref([]);
|
const records = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
|
// Dentro da function — não vaza entre instâncias (audit alta resolvida)
|
||||||
let _lastPatientId = null;
|
let _lastPatientId = null;
|
||||||
|
|
||||||
async function load(patientId) {
|
async function load(patientId) {
|
||||||
@@ -30,15 +36,7 @@ export function usePatientFinancial() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
records.value = [];
|
records.value = [];
|
||||||
try {
|
try {
|
||||||
const { data, error: err } = await supabase
|
records.value = await listFinancialRecordsByPatient(patientId);
|
||||||
.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 || [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Falha ao carregar lançamentos.';
|
error.value = e?.message || 'Falha ao carregar lançamentos.';
|
||||||
records.value = [];
|
records.value = [];
|
||||||
@@ -73,11 +71,7 @@ export function usePatientFinancial() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status financeiro detalhado pra KPI da Visao Geral.
|
* Status financeiro detalhado pra KPI da Visão 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
|
|
||||||
*/
|
*/
|
||||||
const statusFinanceiro = computed(() => {
|
const statusFinanceiro = computed(() => {
|
||||||
const recs = records.value;
|
const recs = records.value;
|
||||||
@@ -90,9 +84,10 @@ export function usePatientFinancial() {
|
|||||||
const vencidos = pendentes.filter(
|
const vencidos = pendentes.filter(
|
||||||
(r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now
|
(r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now
|
||||||
);
|
);
|
||||||
const proxVenc = pendentes
|
const proxVenc =
|
||||||
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
|
pendentes
|
||||||
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
|
.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 totalPendente = pendentes.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
|
||||||
const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
|
const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
|
||||||
return {
|
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(() =>
|
const recordsOrdenados = computed(() =>
|
||||||
[...records.value].sort((a, b) => {
|
[...records.value].sort((a, b) => {
|
||||||
const da = a.due_date || a.created_at;
|
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) {
|
async function markPaid(recordId) {
|
||||||
if (!recordId || busy.value) return { ok: false, error: 'busy' };
|
if (!recordId || busy.value) return { ok: false, error: 'busy' };
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const { error: err } = await supabase
|
await markFinancialRecordPaid(recordId);
|
||||||
.from('financial_records')
|
|
||||||
.update({ paid_at: new Date().toISOString() })
|
|
||||||
.eq('id', recordId);
|
|
||||||
if (err) throw err;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (e) {
|
} 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 = {}) {
|
async function createRecord(patientId, payload = {}) {
|
||||||
if (!patientId || busy.value) return { ok: false, error: 'busy' };
|
if (!patientId || busy.value) return { ok: false, error: 'busy' };
|
||||||
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
|
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
|
||||||
return { ok: false, error: 'Valor invalido' };
|
return { ok: false, error: 'Valor inválido' };
|
||||||
}
|
}
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
const data = await createFinancialRecord(patientId, payload);
|
||||||
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;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true, data };
|
return { ok: true, data };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -190,18 +138,11 @@ export function usePatientFinancial() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverte: remove paid_at (volta pra pendente). Auto-reload.
|
|
||||||
*/
|
|
||||||
async function markUnpaid(recordId) {
|
async function markUnpaid(recordId) {
|
||||||
if (!recordId || busy.value) return { ok: false, error: 'busy' };
|
if (!recordId || busy.value) return { ok: false, error: 'busy' };
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const { error: err } = await supabase
|
await markFinancialRecordUnpaid(recordId);
|
||||||
.from('financial_records')
|
|
||||||
.update({ paid_at: null })
|
|
||||||
.eq('id', recordId);
|
|
||||||
if (err) throw err;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientMessages.js
|
| Arquivo: src/features/patients/composables/usePatientMessages.js
|
||||||
|
|
|
|
||||||
| Mensagens de conversa do paciente. Carrega 200 mais recentes (in+out)
|
| Mensagens de conversa do paciente. 200 mais recentes (in+out) pra alimentar
|
||||||
| pra alimentar o card "Ultimas mensagens" (Visao Geral, top 4) e os
|
| o card "Últimas mensagens" (top 4) e KPIs da aba Conversas.
|
||||||
| KPIs da aba Conversas. Conversa completa fica no PatientConversationsTab.
|
| Conversa completa fica no PatientConversationsTab.
|
||||||
|
|
|
||||||
|
| I/O delegada ao patientsRepository.
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { listMessagesByPatient } from '@/features/patients/services/patientsRepository';
|
||||||
|
|
||||||
export function usePatientMessages() {
|
export function usePatientMessages() {
|
||||||
const messages = ref([]);
|
const messages = ref([]);
|
||||||
@@ -26,14 +28,7 @@ export function usePatientMessages() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
try {
|
try {
|
||||||
const { data, error: err } = await supabase
|
messages.value = await listMessagesByPatient(patientId);
|
||||||
.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 || [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Falha ao carregar mensagens.';
|
error.value = e?.message || 'Falha ao carregar mensagens.';
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
@@ -43,18 +38,11 @@ export function usePatientMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recentes = computed(() => messages.value.slice(0, 4));
|
const recentes = computed(() => messages.value.slice(0, 4));
|
||||||
const totalIn = computed(() =>
|
const totalIn = computed(() => messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length);
|
||||||
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 totalOut = computed(() =>
|
|
||||||
messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length
|
|
||||||
);
|
|
||||||
const ultimaMensagem = computed(() => messages.value[0] || null);
|
const ultimaMensagem = computed(() => messages.value[0] || null);
|
||||||
const primeiraMensagem = computed(() => messages.value[messages.value.length - 1] || null);
|
const primeiraMensagem = computed(() => messages.value[messages.value.length - 1] || null);
|
||||||
|
|
||||||
/**
|
|
||||||
* Canais unicos usados nas mensagens (whatsapp, sms, email).
|
|
||||||
*/
|
|
||||||
const canais = computed(() => {
|
const canais = computed(() => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
for (const m of messages.value) if (m.channel) set.add(m.channel);
|
for (const m of messages.value) if (m.channel) set.add(m.channel);
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientRecurrences.js
|
| Arquivo: src/features/patients/composables/usePatientRecurrences.js
|
||||||
|
|
|
|
||||||
| Carrega regras de recorrencia (recurrence_rules) filtradas por paciente.
|
| Regras de recorrência (recurrence_rules) do paciente. Usado pela Tab Agenda
|
||||||
| Usado pela Tab Agenda do MelissaPaciente pra mostrar "este paciente tem
|
| do MelissaPaciente pra mostrar "este paciente tem sessão toda segunda 14h"
|
||||||
| sessao toda segunda 14h" e dar acoes inline (cancelar/reativar).
|
| e ações inline (cancelar/reativar).
|
||||||
|
|
|
|
||||||
| Mutations espelham o pattern de MelissaRecorrencias.vue.
|
| I/O delegada ao patientsRepository.
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import {
|
||||||
|
listRecurrencesByPatient,
|
||||||
|
updateRecurrenceStatus
|
||||||
|
} from '@/features/patients/services/patientsRepository';
|
||||||
|
|
||||||
export function usePatientRecurrences() {
|
export function usePatientRecurrences() {
|
||||||
const rules = ref([]);
|
const rules = ref([]);
|
||||||
@@ -31,15 +34,9 @@ export function usePatientRecurrences() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
rules.value = [];
|
rules.value = [];
|
||||||
try {
|
try {
|
||||||
const { data, error: err } = await supabase
|
rules.value = await listRecurrencesByPatient(patientId);
|
||||||
.from('recurrence_rules')
|
|
||||||
.select('*')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('start_date', { ascending: false });
|
|
||||||
if (err) throw err;
|
|
||||||
rules.value = data || [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Falha ao carregar recorrencias.';
|
error.value = e?.message || 'Falha ao carregar recorrências.';
|
||||||
rules.value = [];
|
rules.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -50,11 +47,7 @@ export function usePatientRecurrences() {
|
|||||||
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
|
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const { error: err } = await supabase
|
await updateRecurrenceStatus(ruleId, 'cancelado');
|
||||||
.from('recurrence_rules')
|
|
||||||
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
|
||||||
.eq('id', ruleId);
|
|
||||||
if (err) throw err;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -68,11 +61,7 @@ export function usePatientRecurrences() {
|
|||||||
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
|
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const { error: err } = await supabase
|
await updateRecurrenceStatus(ruleId, 'ativo');
|
||||||
.from('recurrence_rules')
|
|
||||||
.update({ status: 'ativo', updated_at: new Date().toISOString() })
|
|
||||||
.eq('id', ruleId);
|
|
||||||
if (err) throw err;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -4,23 +4,32 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientSessions.js
|
| Arquivo: src/features/patients/composables/usePatientSessions.js
|
||||||
|
|
|
|
||||||
| Carrega sessoes (agenda_eventos) do paciente. Limit 100 mais recentes
|
| Carrega sessões (agenda_eventos) do paciente. Limit 100 reais + expansão
|
||||||
| ordenadas desc por inicio_em. Compativel com a logica original do
|
| de recorrências do owner dentro de uma janela de 18 meses. Merge desc por
|
||||||
| PatientProntuario.vue.
|
| 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 { ref, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||||
|
import {
|
||||||
|
listSessionsByPatient,
|
||||||
|
createPatientSession,
|
||||||
|
updatePatientSessionStatus,
|
||||||
|
findSessionByRecurrence
|
||||||
|
} from '@/features/patients/services/patientsRepository';
|
||||||
|
|
||||||
export function usePatientSessions() {
|
export function usePatientSessions() {
|
||||||
const sessions = ref([]);
|
const sessions = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
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;
|
let _lastPatientId = null;
|
||||||
|
|
||||||
// Instancia local — refs internos (rules/exceptions) ficam isolados deste consumidor.
|
|
||||||
const { loadAndExpand } = useRecurrence();
|
const { loadAndExpand } = useRecurrence();
|
||||||
|
|
||||||
async function load(patientId) {
|
async function load(patientId) {
|
||||||
@@ -33,23 +42,14 @@ export function usePatientSessions() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
sessions.value = [];
|
sessions.value = [];
|
||||||
try {
|
try {
|
||||||
// 1. Linhas reais — `recurrence_id`/`recurrence_date` inclusos pra
|
// 1. Linhas reais via repository
|
||||||
// mergeWithStoredSessions deduplicar virtuais de sessões já materializadas.
|
const realRows = await listSessionsByPatient(patientId);
|
||||||
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 || [];
|
|
||||||
|
|
||||||
// 2. Expande recorrências do owner + filtra só as deste paciente.
|
// 2. Expande recorrências do owner + filtra pra este paciente.
|
||||||
// Range default: 6 meses atrás → 12 meses à frente (cobre histórico
|
// Range: 6 meses atrás → 12 meses à frente (histórico + ~1 ano de séries futuras).
|
||||||
// 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.
|
|
||||||
let virtualOccs = [];
|
let virtualOccs = [];
|
||||||
try {
|
try {
|
||||||
|
// auth.getUser é context, não data query — pode ficar inline.
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
const ownerId = userData?.user?.id || null;
|
const ownerId = userData?.user?.id || null;
|
||||||
if (ownerId) {
|
if (ownerId) {
|
||||||
@@ -57,7 +57,9 @@ export function usePatientSessions() {
|
|||||||
try {
|
try {
|
||||||
const { useTenantStore } = await import('@/stores/tenantStore');
|
const { useTenantStore } = await import('@/stores/tenantStore');
|
||||||
tenantId = useTenantStore().activeTenantId || null;
|
tenantId = useTenantStore().activeTenantId || null;
|
||||||
} catch { /* sem tenant store — segue */ }
|
} catch {
|
||||||
|
/* sem tenant store — segue */
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
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);
|
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[usePatientSessions] recurrence expand falhou:', e);
|
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];
|
const merged = [...realRows, ...virtualOccs];
|
||||||
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
||||||
sessions.value = merged;
|
sessions.value = merged;
|
||||||
@@ -84,122 +86,63 @@ export function usePatientSessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers derivados — proxima sessao agendada e status corrente
|
// ─── Computeds derivados ────────────────────────────────
|
||||||
|
|
||||||
const proximaSessao = computed(() => {
|
const proximaSessao = computed(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return [...sessions.value]
|
return (
|
||||||
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
|
[...sessions.value]
|
||||||
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null;
|
.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 ultimaSessao = computed(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return sessions.value
|
return (
|
||||||
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
|
sessions.value
|
||||||
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null;
|
.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);
|
const totalSessoes = computed(() => sessions.value.length);
|
||||||
|
|
||||||
// Conta status com regex pra cobrir variantes pt-br
|
const totalRealizadas = computed(() => sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length);
|
||||||
// (realizada/realizado/presente; falta/faltou; cancelada/cancelado/remarcada).
|
const totalFaltas = computed(() => sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length);
|
||||||
const totalRealizadas = computed(() =>
|
const totalCanceladas = computed(() => sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length);
|
||||||
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(() =>
|
const ultimasAtendidas = computed(() =>
|
||||||
sessions.value
|
sessions.value.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || ''))).slice(0, 6)
|
||||||
.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || '')))
|
|
||||||
.slice(0, 6)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
// ─── Mutations ─────────────────────────────────────────
|
||||||
* 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?}.
|
|
||||||
*/
|
|
||||||
async function createSession(patientId, payload = {}) {
|
async function createSession(patientId, payload = {}) {
|
||||||
if (!patientId || busy.value) return { ok: false, error: 'busy' };
|
if (!patientId || busy.value) return { ok: false, error: 'busy' };
|
||||||
if (!payload?.inicio_em || !payload?.fim_em) {
|
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;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
const data = await createPatientSession(patientId, payload);
|
||||||
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;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true, data };
|
return { ok: true, data };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: e?.message || 'Erro ao agendar sessao' };
|
return { ok: false, error: e?.message || 'Erro ao agendar sessão' };
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
|
* Atualiza status — aceita UUID string OU row inteira. Se virtual
|
||||||
* ao final pra refletir o novo estado nos computeds derivados.
|
* (is_occurrence), materializa antes via createPatientSession.
|
||||||
*
|
|
||||||
* 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}.
|
|
||||||
*/
|
*/
|
||||||
async function updateStatus(sessionOrId, novoStatus) {
|
async function updateStatus(sessionOrId, novoStatus) {
|
||||||
if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
|
if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
// Caminho A — string UUID legado ou row real (id é UUID real).
|
|
||||||
const isObject = typeof sessionOrId === 'object' && sessionOrId !== null;
|
const isObject = typeof sessionOrId === 'object' && sessionOrId !== null;
|
||||||
const isVirtual = isObject && !!sessionOrId.is_occurrence;
|
const isVirtual = isObject && !!sessionOrId.is_occurrence;
|
||||||
|
|
||||||
@@ -208,16 +151,12 @@ export function usePatientSessions() {
|
|||||||
if (!realId || typeof realId !== 'string' || realId.startsWith('rec::')) {
|
if (!realId || typeof realId !== 'string' || realId.startsWith('rec::')) {
|
||||||
return { ok: false, error: 'ID inválido pra atualizar status (virtual sem row).' };
|
return { ok: false, error: 'ID inválido pra atualizar status (virtual sem row).' };
|
||||||
}
|
}
|
||||||
const { error: err } = await supabase
|
await updatePatientSessionStatus(realId, novoStatus);
|
||||||
.from('agenda_eventos')
|
|
||||||
.update({ status: novoStatus })
|
|
||||||
.eq('id', realId);
|
|
||||||
if (err) throw err;
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caminho B — ocorrência virtual: materializar antes de atualizar.
|
// Virtual: materializar antes
|
||||||
const row = sessionOrId;
|
const row = sessionOrId;
|
||||||
const rid = row.recurrence_id;
|
const rid = row.recurrence_id;
|
||||||
const rDate = row.recurrence_date || row.original_date || String(row.inicio_em || '').slice(0, 10);
|
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.' };
|
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 existing = await findSessionByRecurrence(rid, rDate);
|
||||||
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;
|
|
||||||
|
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
const { error: upErr } = await supabase
|
await updatePatientSessionStatus(existing.id, novoStatus);
|
||||||
.from('agenda_eventos')
|
|
||||||
.update({ status: novoStatus })
|
|
||||||
.eq('id', existing.id);
|
|
||||||
if (upErr) throw upErr;
|
|
||||||
} else {
|
} else {
|
||||||
// Materializa NOVA row a partir da virtual. Owner/tenant via auth+store.
|
// Cria row real materializada (createPatientSession suporta recurrence_id/date)
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
await createPatientSession(row.patient_id || row.paciente_id || _lastPatientId, {
|
||||||
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,
|
|
||||||
inicio_em: row.inicio_em,
|
inicio_em: row.inicio_em,
|
||||||
fim_em: row.fim_em,
|
fim_em: row.fim_em,
|
||||||
|
status: novoStatus,
|
||||||
modalidade: row.modalidade || 'presencial',
|
modalidade: row.modalidade || 'presencial',
|
||||||
titulo: row.titulo || null,
|
tipo: row.tipo || 'sessao',
|
||||||
titulo_custom: row.titulo_custom || null,
|
titulo: row.titulo,
|
||||||
observacoes: row.observacoes || null,
|
titulo_custom: row.titulo_custom,
|
||||||
determined_commitment_id: row.determined_commitment_id || null,
|
observacoes: row.observacoes,
|
||||||
price: row.price ?? null
|
recurrence_id: rid,
|
||||||
};
|
recurrence_date: rDate,
|
||||||
const { error: insErr } = await supabase
|
determined_commitment_id: row.determined_commitment_id,
|
||||||
.from('agenda_eventos')
|
price: row.price
|
||||||
.insert([newRow]);
|
});
|
||||||
if (insErr) throw insErr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
| Agência PSI
|
| Agência PSI
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatientSupportContacts.js
|
| Arquivo: src/features/patients/composables/usePatientSupportContacts.js
|
||||||
| V#9 — composable de contatos de suporte do paciente (responsável, parente,
|
| V#9 — contatos de suporte do paciente (responsável, parente, amigo).
|
||||||
| amigo). Encapsula CRUD + estado reativo.
|
| Encapsula CRUD + estado reativo. I/O delegada ao patientsRepository.
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
|
||||||
import { digitsOnly, fmtPhone } from '@/utils/validators';
|
import { digitsOnly, fmtPhone } from '@/utils/validators';
|
||||||
|
import {
|
||||||
|
listSupportContactsByPatient,
|
||||||
|
replacePatientSupportContacts
|
||||||
|
} from '@/features/patients/services/patientsRepository';
|
||||||
|
|
||||||
function novoContato() {
|
function novoContato() {
|
||||||
return {
|
return {
|
||||||
@@ -55,12 +58,7 @@ export function usePatientSupportContacts() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const data = await listSupportContactsByPatient(patientId);
|
||||||
.from('patient_support_contacts')
|
|
||||||
.select('*')
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.order('is_primario', { ascending: false });
|
|
||||||
if (error) throw error;
|
|
||||||
contatos.value = (data || []).map((c) => ({
|
contatos.value = (data || []).map((c) => ({
|
||||||
_k: c.id,
|
_k: c.id,
|
||||||
nome: c.nome || '',
|
nome: c.nome || '',
|
||||||
@@ -71,6 +69,7 @@ export function usePatientSupportContacts() {
|
|||||||
is_primario: !!c.is_primario
|
is_primario: !!c.is_primario
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
|
// Fallback silencioso pra não quebrar prontuário se tabela estiver vazia/RLS
|
||||||
contatos.value = [];
|
contatos.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -79,26 +78,13 @@ export function usePatientSupportContacts() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitui contatos do paciente: deleta tudo do owner + reinserta os com nome.
|
* 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) {
|
async function save(patientId, tenantId, ownerId) {
|
||||||
if (!patientId) throw new Error('patientId obrigatório');
|
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
|
const rows = contatos.value
|
||||||
.filter((c) => c.nome.trim())
|
.filter((c) => c.nome.trim())
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
patient_id: patientId,
|
|
||||||
owner_id: ownerId,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
nome: c.nome.trim() || null,
|
nome: c.nome.trim() || null,
|
||||||
relacao: c.relacao || null,
|
relacao: c.relacao || null,
|
||||||
tipo: c.tipo || null,
|
tipo: c.tipo || null,
|
||||||
@@ -107,9 +93,8 @@ export function usePatientSupportContacts() {
|
|||||||
is_primario: !!c.is_primario
|
is_primario: !!c.is_primario
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!rows.length) return;
|
// Repository injeta patient_id, owner_id, tenant_id em cada row
|
||||||
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
|
await replacePatientSupportContacts(patientId, rows, { tenantId, ownerId });
|
||||||
if (ins) throw ins;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { contatos, loading, add, remove, reset, iniciaisFor, load, save };
|
return { contatos, loading, add, remove, reset, iniciaisFor, load, save };
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
| Agência PSI
|
| Agência PSI
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Arquivo: src/features/patients/composables/usePatients.js
|
| 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';
|
import { ref } from 'vue';
|
||||||
@@ -19,11 +21,11 @@ import {
|
|||||||
export function usePatients() {
|
export function usePatients() {
|
||||||
const rows = ref([]);
|
const rows = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref(null);
|
const error = ref('');
|
||||||
|
|
||||||
async function load(opts) {
|
async function load(opts) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
rows.value = await listPatients(opts);
|
rows.value = await listPatients(opts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -35,19 +37,55 @@ export function usePatients() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getById(id, opts) {
|
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) {
|
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) {
|
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) {
|
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 };
|
return { rows, loading, error, load, getById, create, update, remove };
|
||||||
|
|||||||
@@ -5,25 +5,41 @@
|
|||||||
| Arquivo: src/features/patients/services/patientsRepository.js
|
| Arquivo: src/features/patients/services/patientsRepository.js
|
||||||
| V#3 — fundação: queries de patients centralizadas.
|
| V#3 — fundação: queries de patients centralizadas.
|
||||||
|
|
|
|
||||||
| Mesmo padrão de feature/agenda/services/agendaRepository.js. Pages devem
|
| Pages e composables devem chamar este repo em vez de fazer
|
||||||
| chamar este repo em vez de fazer supabase.from('patients') direto.
|
| supabase.from('patients') direto.
|
||||||
|
|
|
||||||
|
| Inclui também reads cross-feature em escopo de paciente (agenda_eventos,
|
||||||
|
| financial_records, documents, recurrence_rules, conversation_messages,
|
||||||
|
| patient_support_contacts) — usados pelos sub-composables do prontuário.
|
||||||
|
| Quando M4 (Financeiro) / M6 (Notificações/Conversations) padronizarem
|
||||||
|
| esses domínios, as funções respectivas migram pra repositories nativos.
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
|
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
|
||||||
|
import {
|
||||||
|
PATIENTS_SELECT_BASE,
|
||||||
|
PATIENT_SESSIONS_SELECT,
|
||||||
|
PATIENT_FINANCIAL_RECORDS_SELECT,
|
||||||
|
PATIENT_DOCUMENTS_SELECT,
|
||||||
|
PATIENT_MESSAGES_SELECT,
|
||||||
|
PATIENT_RECURRENCE_RULES_SELECT,
|
||||||
|
PATIENT_SUPPORT_CONTACTS_SELECT,
|
||||||
|
PATIENT_GROUPS_SELECT,
|
||||||
|
PATIENT_GROUPS_SELECT_BRIEF,
|
||||||
|
PATIENT_TAGS_SELECT,
|
||||||
|
PATIENT_TAGS_SELECT_BRIEF
|
||||||
|
} from './patientsSelects';
|
||||||
|
|
||||||
const PATIENTS_SELECT_BASE = `
|
// ─── Helpers internos ────────────────────────────────────────────────────────
|
||||||
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
|
|
||||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
function resolveTenantId(tenantIdArg) {
|
||||||
cpf, rg, data_nascimento, naturalidade, genero, estado_civil,
|
const tenantStore = useTenantStore();
|
||||||
profissao, escolaridade, status,
|
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
|
||||||
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
|
assertTenantId(tenantId);
|
||||||
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
|
return tenantId;
|
||||||
cobranca_no_responsavel,
|
}
|
||||||
onde_nos_conheceu, encaminhado_por, observacoes,
|
|
||||||
last_attended_at, created_at, updated_at,
|
|
||||||
risco_sinalizado_por, convenio_id, patient_scope
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Patients core
|
// Patients core
|
||||||
@@ -31,12 +47,11 @@ const PATIENTS_SELECT_BASE = `
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lista pacientes do tenant ativo. Aceita filtros opcionais.
|
* Lista pacientes do tenant ativo. Aceita filtros opcionais.
|
||||||
* @param {object} opts - { tenantId, ownerId?, includeInactive?, limit? }
|
|
||||||
*/
|
*/
|
||||||
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
||||||
assertTenantId(tenantId);
|
const tid = resolveTenantId(tenantId);
|
||||||
|
|
||||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId);
|
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tid);
|
||||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||||
if (!includeInactive) q = q.neq('status', 'Inativo');
|
if (!includeInactive) q = q.neq('status', 'Inativo');
|
||||||
if (limit) q = q.limit(limit);
|
if (limit) q = q.limit(limit);
|
||||||
@@ -49,22 +64,21 @@ export async function listPatients({ tenantId, ownerId = null, includeInactive =
|
|||||||
|
|
||||||
export async function getPatientById(id, { tenantId } = {}) {
|
export async function getPatientById(id, { tenantId } = {}) {
|
||||||
if (!id) throw new Error('id obrigatório');
|
if (!id) throw new Error('id obrigatório');
|
||||||
assertTenantId(tenantId);
|
const tid = resolveTenantId(tenantId);
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).eq('tenant_id', tid).maybeSingle();
|
||||||
.from('patients')
|
|
||||||
.select(PATIENTS_SELECT_BASE)
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('tenant_id', tenantId)
|
|
||||||
.maybeSingle();
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPatient(payload) {
|
export async function createPatient(payload) {
|
||||||
const tenantId = payload?.tenant_id;
|
const tid = resolveTenantId(payload?.tenant_id);
|
||||||
assertTenantId(tenantId);
|
// owner_id SEMPRE injetado do uid logado (não aceita do payload).
|
||||||
const ownerId = payload?.owner_id || (await getUid());
|
// Audit baseline alta (2026-05-20): aceitar do payload permitiria
|
||||||
const row = { ...payload, tenant_id: tenantId, owner_id: ownerId };
|
// criar pacientes "de outro terapeuta". Repository é defesa em profundidade.
|
||||||
|
const ownerId = await getUid();
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const { owner_id: _dropped, ...rest } = payload || {};
|
||||||
|
const row = { ...rest, tenant_id: tid, owner_id: ownerId };
|
||||||
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
|
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
@@ -72,84 +86,27 @@ export async function createPatient(payload) {
|
|||||||
|
|
||||||
export async function updatePatient(id, patch, { tenantId } = {}) {
|
export async function updatePatient(id, patch, { tenantId } = {}) {
|
||||||
if (!id) throw new Error('id obrigatório');
|
if (!id) throw new Error('id obrigatório');
|
||||||
assertTenantId(tenantId);
|
const tid = resolveTenantId(tenantId);
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase.from('patients').update(patch).eq('id', id).eq('tenant_id', tid).select(PATIENTS_SELECT_BASE).single();
|
||||||
.from('patients')
|
|
||||||
.update(patch)
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('tenant_id', tenantId)
|
|
||||||
.select(PATIENTS_SELECT_BASE)
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeletePatient(id, { tenantId } = {}) {
|
export async function softDeletePatient(id, { tenantId } = {}) {
|
||||||
if (!id) throw new Error('id obrigatório');
|
if (!id) throw new Error('id obrigatório');
|
||||||
assertTenantId(tenantId);
|
const tid = resolveTenantId(tenantId);
|
||||||
const { error } = await supabase
|
const { error } = await supabase.from('patients').update({ status: 'Arquivado' }).eq('id', id).eq('tenant_id', tid);
|
||||||
.from('patients')
|
|
||||||
.update({ status: 'Arquivado' })
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('tenant_id', tenantId);
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pra restaurar um paciente arquivado, use `reactivatePatient` do
|
// Pra restaurar paciente arquivado, use `reactivatePatient` do `usePatientLifecycle`.
|
||||||
// composable `usePatientLifecycle` — fonte única de verdade pra toda
|
|
||||||
// transição de status (Inativo/Arquivado/Alta/Encaminhado → Ativo).
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Groups
|
// Patient Relations (groups + tags)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
export async function listGroups({ tenantId, ownerId = null } = {}) {
|
|
||||||
assertTenantId(tenantId);
|
|
||||||
let q = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').eq('tenant_id', tenantId).eq('is_active', true);
|
|
||||||
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
|
|
||||||
q = q.order('nome', { ascending: true });
|
|
||||||
const { data, error } = await q;
|
|
||||||
if (error) throw error;
|
|
||||||
return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
|
|
||||||
if (!patientIds?.length) return [];
|
|
||||||
assertTenantId(tenantId);
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('patient_group_patient')
|
|
||||||
.select('patient_id, patient_group_id')
|
|
||||||
.eq('tenant_id', tenantId)
|
|
||||||
.in('patient_id', patientIds);
|
|
||||||
if (error) throw error;
|
|
||||||
return data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
// Tags
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
export async function listTags({ tenantId, ownerId = null } = {}) {
|
|
||||||
assertTenantId(tenantId);
|
|
||||||
let q = supabase.from('patient_tags').select('id, nome, cor, owner_id').eq('tenant_id', tenantId);
|
|
||||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
|
||||||
const { data, error } = await q;
|
|
||||||
if (error) throw error;
|
|
||||||
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
|
|
||||||
if (!patientIds?.length) return [];
|
|
||||||
assertTenantId(tenantId);
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('patient_patient_tag')
|
|
||||||
.select('patient_id, tag_id')
|
|
||||||
.eq('tenant_id', tenantId)
|
|
||||||
.in('patient_id', patientIds);
|
|
||||||
if (error) throw error;
|
|
||||||
return data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retorna {groupIds, tagIds} de um paciente.
|
* Retorna {groupIds, tagIds} de um paciente (joins junior tables).
|
||||||
*/
|
*/
|
||||||
export async function getPatientRelations(patientId) {
|
export async function getPatientRelations(patientId) {
|
||||||
if (!patientId) return { groupIds: [], tagIds: [] };
|
if (!patientId) return { groupIds: [], tagIds: [] };
|
||||||
@@ -165,48 +122,398 @@ export async function getPatientRelations(patientId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Groups ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listGroups({ tenantId, ownerId = null } = {}) {
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
let q = supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('tenant_id', tid).eq('is_active', true);
|
||||||
|
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
|
||||||
|
q = q.order('nome', { ascending: true });
|
||||||
|
const { data, error } = await q;
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
|
||||||
|
if (!patientIds?.length) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase.from('patient_group_patient').select('patient_id, patient_group_id').eq('tenant_id', tid).in('patient_id', patientIds);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lê grupos por ids (brief — id+name). Usado em prontuário pra mostrar pílulas.
|
||||||
|
*/
|
||||||
|
export async function getGroupsByIds(ids) {
|
||||||
|
if (!ids?.length) return [];
|
||||||
|
const { data, error } = await supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitui o grupo do paciente (1:1 — sistema atual).
|
* Substitui o grupo do paciente (1:1 — sistema atual).
|
||||||
*/
|
*/
|
||||||
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
|
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
|
||||||
if (!patientId) throw new Error('patientId obrigatório');
|
if (!patientId) throw new Error('patientId obrigatório');
|
||||||
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId);
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId).eq('tenant_id', tid);
|
||||||
if (del) throw del;
|
if (del) throw del;
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
const { error: ins } = await supabase
|
const { error: ins } = await supabase.from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tid });
|
||||||
.from('patient_group_patient')
|
|
||||||
.insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tenantId });
|
|
||||||
if (ins) throw ins;
|
if (ins) throw ins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tags ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listTags({ tenantId, ownerId = null } = {}) {
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
let q = supabase.from('patient_tags').select(PATIENT_TAGS_SELECT).eq('tenant_id', tid);
|
||||||
|
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||||
|
const { data, error } = await q;
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
|
||||||
|
if (!patientIds?.length) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase.from('patient_patient_tag').select('patient_id, tag_id').eq('tenant_id', tid).in('patient_id', patientIds);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitui as tags do paciente (lista). Limpa antigas do owner + inserta as novas.
|
* Lê tags por ids (brief — id+name+color). Usado em prontuário.
|
||||||
|
*/
|
||||||
|
export async function getTagsByIds(ids) {
|
||||||
|
if (!ids?.length) return [];
|
||||||
|
const { data, error } = await supabase.from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitui as tags do paciente.
|
||||||
*/
|
*/
|
||||||
export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId } = {}) {
|
export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId } = {}) {
|
||||||
if (!patientId) throw new Error('patientId obrigatório');
|
if (!patientId) throw new Error('patientId obrigatório');
|
||||||
if (!ownerId) throw new Error('ownerId obrigatório');
|
if (!ownerId) throw new Error('ownerId obrigatório');
|
||||||
const { error: del } = await supabase
|
const tid = resolveTenantId(tenantId);
|
||||||
.from('patient_patient_tag')
|
|
||||||
.delete()
|
const { error: del } = await supabase.from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.eq('owner_id', ownerId);
|
|
||||||
if (del) throw del;
|
if (del) throw del;
|
||||||
|
|
||||||
const clean = Array.from(new Set((tagIds || []).filter(Boolean)));
|
const clean = Array.from(new Set((tagIds || []).filter(Boolean)));
|
||||||
if (!clean.length) return;
|
if (!clean.length) return;
|
||||||
const { error: ins } = await supabase
|
const { error: ins } = await supabase.from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tid })));
|
||||||
.from('patient_patient_tag')
|
|
||||||
.insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tenantId })));
|
|
||||||
if (ins) throw ins;
|
if (ins) throw ins;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Sessões agregadas (V#8 — get_patient_session_counts RPC)
|
// Sessions (agenda_eventos) em escopo de paciente
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cross-feature: chamadas pelo usePatientSessions. Migração futura: quando
|
||||||
|
// agendaRepository for ampliado pra exportar listByPatient, este módulo deixa
|
||||||
|
// de duplicar.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista sessões reais (agenda_eventos) do paciente, 100 mais recentes desc.
|
||||||
|
*/
|
||||||
|
export async function listSessionsByPatient(patientId, { tenantId } = {}) {
|
||||||
|
if (!patientId) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select(PATIENT_SESSIONS_SELECT)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.eq('patient_id', patientId)
|
||||||
|
.order('inicio_em', { ascending: false })
|
||||||
|
.limit(100);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria sessão pro paciente. owner_id + tenant_id injetados pelo repository.
|
||||||
|
*
|
||||||
|
* payload aceita: inicio_em, fim_em, status?, modalidade?, tipo?, titulo?,
|
||||||
|
* titulo_custom?, observacoes?, recurrence_id?, recurrence_date?,
|
||||||
|
* determined_commitment_id?, price?
|
||||||
|
*/
|
||||||
|
export async function createPatientSession(patientId, payload) {
|
||||||
|
if (!patientId) throw new Error('patientId obrigatório');
|
||||||
|
if (!payload?.inicio_em || !payload?.fim_em) throw new Error('Início/fim obrigatórios');
|
||||||
|
const uid = await getUid();
|
||||||
|
const tid = resolveTenantId();
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
patient_id: patientId,
|
||||||
|
owner_id: uid,
|
||||||
|
tenant_id: tid,
|
||||||
|
inicio_em: payload.inicio_em,
|
||||||
|
fim_em: payload.fim_em,
|
||||||
|
status: payload.status || 'agendado',
|
||||||
|
modalidade: payload.modalidade || 'presencial',
|
||||||
|
tipo: payload.tipo || 'sessao',
|
||||||
|
titulo: payload.titulo ? String(payload.titulo).trim() || null : null,
|
||||||
|
titulo_custom: payload.titulo_custom ? String(payload.titulo_custom).trim() || null : null,
|
||||||
|
observacoes: payload.observacoes ? String(payload.observacoes).trim() || null : null,
|
||||||
|
recurrence_id: payload.recurrence_id || null,
|
||||||
|
recurrence_date: payload.recurrence_date || null,
|
||||||
|
determined_commitment_id: payload.determined_commitment_id || null,
|
||||||
|
price: payload.price ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabase.from('agenda_eventos').insert([row]).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualiza só o status de uma sessão (mutation comum em prontuário).
|
||||||
|
*/
|
||||||
|
export async function updatePatientSessionStatus(sessionId, status, { tenantId } = {}) {
|
||||||
|
if (!sessionId) throw new Error('sessionId obrigatório');
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { error } = await supabase.from('agenda_eventos').update({ status }).eq('id', sessionId).eq('tenant_id', tid);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procura sessão materializada por (recurrence_id, recurrence_date).
|
||||||
|
* Usado pra decidir entre UPDATE (já existe) e INSERT (materializar virtual).
|
||||||
|
*/
|
||||||
|
export async function findSessionByRecurrence(recurrenceId, recurrenceDate) {
|
||||||
|
if (!recurrenceId || !recurrenceDate) return null;
|
||||||
|
const { data, error } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Financial Records em escopo de paciente
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cross-feature: migra pra features/financeiro/services no Módulo 4.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista lançamentos do paciente (type=receita, 100 mais recentes).
|
||||||
|
*/
|
||||||
|
export async function listFinancialRecordsByPatient(patientId, { tenantId } = {}) {
|
||||||
|
if (!patientId) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select(PATIENT_FINANCIAL_RECORDS_SELECT)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.eq('patient_id', patientId)
|
||||||
|
.eq('type', 'receita')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(100);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria lançamento manual pro paciente (type=receita).
|
||||||
|
*/
|
||||||
|
export async function createFinancialRecord(patientId, payload) {
|
||||||
|
if (!patientId) throw new Error('patientId obrigatório');
|
||||||
|
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
|
||||||
|
throw new Error('Valor inválido');
|
||||||
|
}
|
||||||
|
const uid = await getUid();
|
||||||
|
const tid = resolveTenantId();
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
patient_id: patientId,
|
||||||
|
owner_id: uid,
|
||||||
|
tenant_id: tid,
|
||||||
|
type: 'receita',
|
||||||
|
amount: Number(payload.amount),
|
||||||
|
due_date: payload.due_date || null,
|
||||||
|
description: payload.description ? String(payload.description).trim() || null : null,
|
||||||
|
payment_method: payload.payment_method || null,
|
||||||
|
paid_at: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabase.from('financial_records').insert([row]).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca lançamento como pago (paid_at = now).
|
||||||
|
*/
|
||||||
|
export async function markFinancialRecordPaid(recordId, { tenantId } = {}) {
|
||||||
|
if (!recordId) throw new Error('recordId obrigatório');
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { error } = await supabase.from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId).eq('tenant_id', tid);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverte: remove paid_at (volta pra pendente).
|
||||||
|
*/
|
||||||
|
export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) {
|
||||||
|
if (!recordId) throw new Error('recordId obrigatório');
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { error } = await supabase.from('financial_records').update({ paid_at: null }).eq('id', recordId).eq('tenant_id', tid);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Documents em escopo de paciente (deleted_at IS NULL)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listDocumentsByPatient(patientId, { tenantId } = {}) {
|
||||||
|
if (!patientId) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('documents')
|
||||||
|
.select(PATIENT_DOCUMENTS_SELECT)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.eq('patient_id', patientId)
|
||||||
|
.is('deleted_at', null)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Messages (conversation_messages) em escopo de paciente
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cross-feature: migra pra features/conversations no Módulo 6.
|
||||||
|
|
||||||
|
export async function listMessagesByPatient(patientId, { tenantId } = {}) {
|
||||||
|
if (!patientId) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_messages')
|
||||||
|
.select(PATIENT_MESSAGES_SELECT)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.eq('patient_id', patientId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Recurrence Rules em escopo de paciente
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cross-feature: migra pra agendaRepository quando padronizado.
|
||||||
|
|
||||||
|
export async function listRecurrencesByPatient(patientId, { tenantId } = {}) {
|
||||||
|
if (!patientId) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('recurrence_rules')
|
||||||
|
.select(PATIENT_RECURRENCE_RULES_SELECT)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.eq('patient_id', patientId)
|
||||||
|
.order('start_date', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualiza status da regra de recorrência (cancelado/ativo).
|
||||||
|
*/
|
||||||
|
export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {}) {
|
||||||
|
if (!ruleId) throw new Error('ruleId obrigatório');
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { error } = await supabase.from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId).eq('tenant_id', tid);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Support Contacts (patient_support_contacts)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listSupportContactsByPatient(patientId, { tenantId } = {}) {
|
||||||
|
if (!patientId) return [];
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
const { data, error } = await supabase.from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('is_primario', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitui todos os contatos do paciente (delete-then-insert).
|
||||||
|
* Pattern original do composable.
|
||||||
|
*
|
||||||
|
* @param {Array} contacts - rows já mapeadas (sem patient_id/tenant_id/owner_id — injetados aqui)
|
||||||
|
*/
|
||||||
|
export async function replacePatientSupportContacts(patientId, contacts, { tenantId, ownerId } = {}) {
|
||||||
|
if (!patientId) throw new Error('patientId obrigatório');
|
||||||
|
if (!ownerId) throw new Error('ownerId obrigatório');
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
|
||||||
|
const { error: del } = await supabase.from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
|
||||||
|
if (del) throw del;
|
||||||
|
|
||||||
|
if (!contacts?.length) return;
|
||||||
|
|
||||||
|
const rows = contacts.map((c) => ({
|
||||||
|
...c,
|
||||||
|
patient_id: patientId,
|
||||||
|
owner_id: ownerId,
|
||||||
|
tenant_id: tid
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
|
||||||
|
if (ins) throw ins;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Patient Intake Requests (convert flow)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cross-feature: usado pelos 2 callers de "Cadastros recebidos"
|
||||||
|
// (CadastrosRecebidosPage + MelissaCadastrosRecebidos). Centraliza a
|
||||||
|
// transição intake → patient pra eliminar duplicação (Fase 2 — Graphify hotspot).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca um patient_intake_request como convertido em paciente.
|
||||||
|
* Caller deve já ter criado o paciente via createPatient() antes.
|
||||||
|
*
|
||||||
|
* @param {string} intakeId
|
||||||
|
* @param {string} patientId - id do paciente recém-criado
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {string} [opts.tenantId]
|
||||||
|
*/
|
||||||
|
export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}) {
|
||||||
|
if (!intakeId) throw new Error('intakeId obrigatório.');
|
||||||
|
if (!patientId) throw new Error('patientId obrigatório.');
|
||||||
|
|
||||||
|
// tenant_id no patient_intake_requests pode ser nullable (intake público sem tenant)
|
||||||
|
// — só filtramos se passado explícito.
|
||||||
|
let q = supabase
|
||||||
|
.from('patient_intake_requests')
|
||||||
|
.update({
|
||||||
|
status: 'converted',
|
||||||
|
converted_patient_id: patientId,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', intakeId);
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
const tid = resolveTenantId(tenantId);
|
||||||
|
q = q.eq('tenant_id', tid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await q;
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Sessions count aggregate (RPC já existente)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER.
|
* Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER.
|
||||||
* @param {string[]} patientIds
|
|
||||||
* @returns {Array<{patient_id, session_count, last_session_at}>}
|
|
||||||
*/
|
*/
|
||||||
export async function getSessionCounts(patientIds) {
|
export async function getSessionCounts(patientIds) {
|
||||||
if (!patientIds?.length) return [];
|
if (!patientIds?.length) return [];
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/features/patients/services/patientsSelects.js
|
||||||
|
|
|
||||||
|
| Fonte única de SELECTs do feature patients. Inclui também SELECTs de
|
||||||
|
| tabelas relacionadas (agenda_eventos, financial_records, etc) quando
|
||||||
|
| usadas SOB ESCOPO de paciente — composables fazem listing por paciente
|
||||||
|
| e precisam de SELECTs estáveis.
|
||||||
|
|
|
||||||
|
| Quando os módulos M4 (Financeiro) / M6 (Notificações/Conversations)
|
||||||
|
| forem padronizados, os SELECTs cross-feature listados aqui podem migrar
|
||||||
|
| pra repositories nativos. Por ora, centralizar no patients é pragmático.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** SELECT base de patients — usado em listPatients, getPatientById, etc. */
|
||||||
|
export const PATIENTS_SELECT_BASE = `
|
||||||
|
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
|
||||||
|
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||||
|
cpf, rg, data_nascimento, naturalidade, genero, estado_civil,
|
||||||
|
profissao, escolaridade, status,
|
||||||
|
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
|
||||||
|
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
|
||||||
|
cobranca_no_responsavel,
|
||||||
|
onde_nos_conheceu, encaminhado_por, observacoes,
|
||||||
|
last_attended_at, created_at, updated_at,
|
||||||
|
risco_sinalizado_por, convenio_id, patient_scope
|
||||||
|
`
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// ─── Cross-feature reads em escopo de paciente ──────────────────────────────
|
||||||
|
|
||||||
|
/** Sessões (agenda_eventos) listadas por paciente. */
|
||||||
|
export const PATIENT_SESSIONS_SELECT = `
|
||||||
|
id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom,
|
||||||
|
observacoes, patient_id, recurrence_id, recurrence_date
|
||||||
|
`
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** Lançamentos financeiros do paciente. */
|
||||||
|
export const PATIENT_FINANCIAL_RECORDS_SELECT = `
|
||||||
|
id, type, amount, due_date, paid_at, description, payment_method, category, created_at
|
||||||
|
`
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** Documentos do paciente — KPI/timeline (campos leves). */
|
||||||
|
export const PATIENT_DOCUMENTS_SELECT = `
|
||||||
|
id, tipo_documento, created_at, status_revisao, tamanho_bytes
|
||||||
|
`
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** Mensagens recentes do paciente. */
|
||||||
|
export const PATIENT_MESSAGES_SELECT = `
|
||||||
|
id, body, direction, created_at, channel, kanban_status
|
||||||
|
`
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** Regras de recorrência — composable usa todos os campos (UI rica). */
|
||||||
|
export const PATIENT_RECURRENCE_RULES_SELECT = '*';
|
||||||
|
|
||||||
|
/** Contatos de suporte (responsável, parente, etc) — formulário usa todos os campos. */
|
||||||
|
export const PATIENT_SUPPORT_CONTACTS_SELECT = '*';
|
||||||
|
|
||||||
|
// ─── Patient-native selects ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Grupos de pacientes (estrutura completa) — listGroups. */
|
||||||
|
export const PATIENT_GROUPS_SELECT = 'id, nome, cor, is_system, owner_id, is_active';
|
||||||
|
|
||||||
|
/** Grupos — versão brief (id+nome) usada em getGroupsByIds. */
|
||||||
|
export const PATIENT_GROUPS_SELECT_BRIEF = 'id, nome';
|
||||||
|
|
||||||
|
/** Tags do paciente — completa com owner. */
|
||||||
|
export const PATIENT_TAGS_SELECT = 'id, nome, cor, owner_id';
|
||||||
|
|
||||||
|
/** Tags — brief com cor (display em pílulas). */
|
||||||
|
export const PATIENT_TAGS_SELECT_BRIEF = 'id, nome, cor';
|
||||||
@@ -17,6 +17,8 @@ import { useToast } from 'primevue/usetoast';
|
|||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages — extração pro repository.
|
||||||
|
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
|
||||||
import { brToISO, isoToBR } from '@/utils/dateBR';
|
import { brToISO, isoToBR } from '@/utils/dateBR';
|
||||||
// Dialog/Textarea/Button auto-imported via PrimeVueResolver
|
// Dialog/Textarea/Button auto-imported via PrimeVueResolver
|
||||||
|
|
||||||
@@ -429,16 +431,12 @@ async function convertToPatient() {
|
|||||||
if (patientPayload[k] === undefined) delete patientPayload[k];
|
if (patientPayload[k] === undefined) delete patientPayload[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: created, error: insErr } = await supabase.from('patients')
|
// Repository chamadas (Fase 2 — convertToPatient de-dup).
|
||||||
.insert(patientPayload).select('id').single();
|
const created = await createPatient(patientPayload);
|
||||||
if (insErr) throw insErr;
|
|
||||||
const patientId = created?.id;
|
const patientId = created?.id;
|
||||||
if (!patientId) throw new Error('Falha ao obter ID do paciente.');
|
if (!patientId) throw new Error('Falha ao obter ID do paciente.');
|
||||||
|
|
||||||
const { error: upErr } = await supabase.from('patient_intake_requests')
|
await markIntakeConverted(item.id, patientId);
|
||||||
.update({ status: 'converted', converted_patient_id: patientId, updated_at: new Date().toISOString() })
|
|
||||||
.eq('id', item.id);
|
|
||||||
if (upErr) throw upErr;
|
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Convertido em paciente', life: 2500 });
|
toast.add({ severity: 'success', summary: 'Convertido em paciente', life: 2500 });
|
||||||
closeDlg();
|
closeDlg();
|
||||||
|
|||||||
Reference in New Issue
Block a user