M1: features/medicos + features/insurance + ComponentCadastroRapido refactor
Modulo 1 da Fase 1 de padronizacao. Novos features/medicos (services + composable useMedicos) e features/insurance (idem). 3 cadastros rapidos (medicos, convenios, ComponentCadastroRapido + Insurance PlanQuickCreateDialog) migrados pra usar os composables novos — zero supabase.from() em UI components. TEST_ACCOUNTS extraido pra src/config/devTestAccounts.js. Topbar ganhou switcher de layout + atalhos M1 via novo useTopbarDevMenuExtras. M1.6 MelissaLayout 90 imports deferida pra sessao dedicada (memoria padronizacao_sweep). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/medicos/composables/useMedicos.js
|
||||
|
|
||||
| Thin wrapper sobre medicosRepository.
|
||||
| Pattern: composable-blueprint Tipo A (default).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { listForOwner, getById, create as repoCreate, update as repoUpdate, softDelete as repoSoftDelete } from '@/features/medicos/services/medicosRepository';
|
||||
|
||||
export function useMedicos() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadForOwner(opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listForOwner(opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar médicos.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchById(id, opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await getById(id, opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar médico.';
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const created = await repoCreate(payload);
|
||||
// Adiciona na lista local mantendo ordenação por nome
|
||||
if (created.ativo) {
|
||||
rows.value = [...rows.value, created].sort((a, b) => (a.nome || '').localeCompare(b.nome || ''));
|
||||
}
|
||||
return created;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar médico.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch, opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await repoUpdate(id, patch, opts);
|
||||
const idx = rows.value.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) {
|
||||
if (updated.ativo) {
|
||||
rows.value[idx] = { ...rows.value[idx], ...updated };
|
||||
} else {
|
||||
rows.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar médico.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function softDelete(id, opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await repoSoftDelete(id, opts);
|
||||
rows.value = rows.value.filter((r) => r.id !== id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover médico.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadForOwner, fetchById, create, update, softDelete };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/medicos/services/_tenantGuards.js
|
||||
|
|
||||
| Guards compartilhados entre repositories do feature medicos.
|
||||
| Pattern canônico — ver blueprints/repository-blueprint.md seção 3.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUid() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado.');
|
||||
return uid;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/medicos/services/medicosRepository.js
|
||||
|
|
||||
| Repository da tabela public.medicos. Pure functions seguindo
|
||||
| blueprints/repository-blueprint.md.
|
||||
|
|
||||
| Schema (servicos_prontuarios.sql):
|
||||
| id, owner_id, tenant_id, nome, crm, especialidade,
|
||||
| telefone_profissional, telefone_pessoal, email, clinica,
|
||||
| cidade, estado='SP', observacoes, ativo=true, created_at, updated_at
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { MEDICO_LIST_SELECT, MEDICO_FULL_SELECT } from './medicosSelects';
|
||||
|
||||
function resolveTenantId(tenantIdArg) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
|
||||
assertTenantId(tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista médicos ativos do owner (escopo terapeuta solo).
|
||||
* Ordenados por nome ascending.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.ownerId] - default: uid logado
|
||||
* @param {string} [opts.tenantId]
|
||||
* @param {boolean} [opts.includeInactive=false]
|
||||
*/
|
||||
export async function listForOwner({ ownerId, tenantId, includeInactive = false } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = ownerId || (await getUid());
|
||||
|
||||
let q = supabase.from('medicos').select(MEDICO_LIST_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('nome', { ascending: true });
|
||||
|
||||
if (!includeInactive) q = q.eq('ativo', true);
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê um médico completo (pra edit). Filtra owner_id + tenant_id por segurança.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function getById(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase.from('medicos').select(MEDICO_FULL_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria médico. Injeta owner_id (uid logado) + tenant_id (store).
|
||||
* Payload aceita os campos canônicos da tabela; o repository sanitiza
|
||||
* trims e nullif vazio.
|
||||
*
|
||||
* @param {Object} payload
|
||||
*/
|
||||
export async function create(payload) {
|
||||
if (!payload) throw new Error('Payload vazio.');
|
||||
if (!payload.nome || !String(payload.nome).trim()) {
|
||||
throw new Error('Nome do médico é obrigatório.');
|
||||
}
|
||||
const uid = await getUid();
|
||||
const tid = resolveTenantId();
|
||||
|
||||
const insertPayload = {
|
||||
...sanitize(payload),
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
ativo: payload.ativo !== false
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('medicos').insert([insertPayload]).select(MEDICO_FULL_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza médico. Filtra por id + tenant_id (defesa em profundidade — RLS reforça).
|
||||
* updated_at é atualizado server-side ou aqui se não houver trigger.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {Object} patch
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function update(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
if (!patch) throw new Error('Patch vazio.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const safePatch = {
|
||||
...sanitize(patch),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.from('medicos').update(safePatch).eq('id', id).eq('tenant_id', tid).select(MEDICO_FULL_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete: marca ativo=false em vez de DELETE. Preserva histórico
|
||||
* de encaminhamentos antigos referentes a este médico.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function softDelete(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase.from('medicos').update({ ativo: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitiza payload: trim em strings, nullif vazio.
|
||||
* Não sanitiza telefones (já chegam digits-only do componente)
|
||||
* nem owner_id/tenant_id/ativo (controlados pelo repository).
|
||||
*/
|
||||
function sanitize(payload) {
|
||||
const stringFields = ['nome', 'crm', 'especialidade', 'telefone_profissional', 'telefone_pessoal', 'email', 'clinica', 'cidade', 'estado', 'observacoes'];
|
||||
|
||||
const out = { ...payload };
|
||||
for (const f of stringFields) {
|
||||
if (f in out) {
|
||||
const v = out[f];
|
||||
if (typeof v === 'string') {
|
||||
const trimmed = v.trim();
|
||||
out[f] = trimmed === '' ? null : trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/medicos/services/medicosSelects.js
|
||||
|
|
||||
| Fonte única de SELECTs da tabela medicos.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* SELECT pra listas (sem campos pesados/sensíveis: telefone_pessoal,
|
||||
* observacoes, email — só carregados em getById/edit).
|
||||
*/
|
||||
export const MEDICO_LIST_SELECT = `
|
||||
id, nome, crm, especialidade,
|
||||
telefone_profissional, clinica, cidade, estado, ativo
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* SELECT completo pra edição (todos os campos).
|
||||
*/
|
||||
export const MEDICO_FULL_SELECT = `
|
||||
id, owner_id, tenant_id,
|
||||
nome, crm, especialidade,
|
||||
telefone_profissional, telefone_pessoal,
|
||||
email, clinica, cidade, estado,
|
||||
observacoes, ativo,
|
||||
created_at, updated_at
|
||||
`.trim();
|
||||
Reference in New Issue
Block a user